@crediolabs/policy-synth 0.1.9 → 0.1.11
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.js +62 -11
- package/dist/errors.d.ts +1 -1
- package/dist/install/build-add-context-rule.d.ts +48 -0
- package/dist/install/build-add-context-rule.js +304 -0
- package/dist/ir/types.d.ts +4 -0
- package/dist/predicate/decode.d.ts +11 -0
- package/dist/predicate/decode.js +234 -0
- package/dist/predicate/encode.js +79 -14
- package/dist/predicate/from-json.d.ts +4 -0
- package/dist/predicate/from-json.js +113 -0
- package/dist/predicate/index.d.ts +2 -0
- package/dist/predicate/index.js +5 -1
- package/dist/registry/identify.js +21 -2
- package/dist/registry/protocols.d.ts +50 -1
- package/dist/registry/protocols.js +88 -0
- package/dist/review-card/builder.d.ts +13 -0
- package/dist/review-card/builder.js +61 -5
- package/dist/review-card/cross-check.js +50 -3
- package/dist/review-card/index.d.ts +1 -1
- package/dist/review-card/index.js +1 -1
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +16 -4
- package/dist/run/schemas.js +14 -0
- package/dist/synth/compose-from-recording.d.ts +4 -0
- package/dist/synth/compose-from-recording.js +34 -8
- package/dist/synth/deny-cases.d.ts +4 -1
- package/dist/synth/deny-cases.js +3 -1
- package/dist/synth/evaluate.js +130 -3
- package/dist/synth/permit-context.d.ts +15 -0
- package/dist/synth/permit-context.js +116 -0
- package/dist/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist/synth/synthesize-from-mandate.js +27 -2
- package/dist/synth/synthesize-from-recording.d.ts +25 -1
- package/dist/synth/synthesize-from-recording.js +80 -3
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +62 -11
- package/dist-cjs/errors.d.ts +1 -1
- package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
- package/dist-cjs/install/build-add-context-rule.js +308 -0
- package/dist-cjs/ir/types.d.ts +4 -0
- package/dist-cjs/predicate/decode.d.ts +11 -0
- package/dist-cjs/predicate/decode.js +239 -0
- package/dist-cjs/predicate/encode.js +79 -14
- package/dist-cjs/predicate/from-json.d.ts +4 -0
- package/dist-cjs/predicate/from-json.js +116 -0
- package/dist-cjs/predicate/index.d.ts +2 -0
- package/dist-cjs/predicate/index.js +10 -2
- package/dist-cjs/registry/identify.js +20 -1
- package/dist-cjs/registry/protocols.d.ts +50 -1
- package/dist-cjs/registry/protocols.js +89 -1
- package/dist-cjs/review-card/builder.d.ts +13 -0
- package/dist-cjs/review-card/builder.js +62 -5
- package/dist-cjs/review-card/cross-check.js +50 -3
- package/dist-cjs/review-card/index.d.ts +1 -1
- package/dist-cjs/review-card/index.js +2 -1
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +16 -4
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
- package/dist-cjs/synth/compose-from-recording.js +34 -8
- package/dist-cjs/synth/deny-cases.d.ts +4 -1
- package/dist-cjs/synth/deny-cases.js +3 -0
- package/dist-cjs/synth/evaluate.js +130 -3
- package/dist-cjs/synth/permit-context.d.ts +15 -0
- package/dist-cjs/synth/permit-context.js +119 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
- package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
- package/dist-cjs/synth/synthesize-from-recording.js +80 -3
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +72 -11
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
- package/src/errors.ts +13 -0
- package/src/install/build-add-context-rule.ts +429 -0
- package/src/ir/types.ts +4 -0
- package/src/predicate/decode.ts +242 -0
- package/src/predicate/encode.ts +110 -13
- package/src/predicate/from-json.ts +124 -0
- package/src/predicate/index.ts +5 -1
- package/src/registry/identify.ts +22 -2
- package/src/registry/protocols.ts +90 -1
- package/src/review-card/builder.ts +57 -5
- package/src/review-card/cross-check.ts +50 -3
- package/src/review-card/index.ts +1 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/compose-from-recording.ts +46 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +131 -3
- package/src/synth/permit-context.ts +137 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +116 -6
- package/src/types.ts +12 -0
|
@@ -139,10 +139,98 @@ export const SOROSWAP_ABI = {
|
|
|
139
139
|
],
|
|
140
140
|
},
|
|
141
141
|
};
|
|
142
|
+
/** OpenZeppelin smart-account ABI subset.
|
|
143
|
+
*
|
|
144
|
+
* Why this lives in the recogniser: every user's smart account has a
|
|
145
|
+
* different C-address, so a per-deployment pin would never cover them.
|
|
146
|
+
* What is the same across every OpenZeppelin multisig account is the
|
|
147
|
+
* small set of public context-rule entrypoints. A recording that
|
|
148
|
+
* invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
|
|
149
|
+
* is overwhelmingly likely to be calling the user's own OpenZeppelin
|
|
150
|
+
* smart account (the canonical reason this product exists); we
|
|
151
|
+
* recognise that pattern by interface, the same way SEP-41 is
|
|
152
|
+
* recognised by interface.
|
|
153
|
+
*
|
|
154
|
+
* SECURITY PROPERTY (load-bearing):
|
|
155
|
+
* - Recognition claims the call TARGETS a particular protocol. We
|
|
156
|
+
* only claim `oz_account` when the (fn, args) shape uniquely matches
|
|
157
|
+
* one of the three entrypoints below AND the call looks like a real
|
|
158
|
+
* context-rule operation. The match is exact: off-by-one arg count,
|
|
159
|
+
* off-by-one arg type, or a method name close-but-wrong returns null
|
|
160
|
+
* (fail-closed). A contract that incidentally happens to expose a
|
|
161
|
+
* single matching fn is not sufficient - the recorder requires ALL
|
|
162
|
+
* invocations to individually match, so a hostile contract with
|
|
163
|
+
* exactly one matching fn would still contribute to the unknown
|
|
164
|
+
* bucket for any other calls.
|
|
165
|
+
* - We do NOT claim `oz_account` for unknown calls, for arg-shape
|
|
166
|
+
* mismatches, or for calls where the recorder could not pin down
|
|
167
|
+
* the (fn, args) shape. The freshness gate then runs at the same
|
|
168
|
+
* 1.0 threshold - lowering the gate for unknown protocols remains
|
|
169
|
+
* a separate, opt-in production override (see RecordInput below).
|
|
170
|
+
*
|
|
171
|
+
* ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
|
|
172
|
+
* pinned from the OpenZeppelin Reloaded `multisig_account_example`
|
|
173
|
+
* contract (commit ef82b65, fetched 2026-07-28).
|
|
174
|
+
*
|
|
175
|
+
* SCOPE NOTE: this ABI only covers the three entrypoints whose args
|
|
176
|
+
* map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
|
|
177
|
+
* ...). `add_context_rule` - the install path - takes `(Symbol,
|
|
178
|
+
* String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
|
|
179
|
+
* `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
|
|
180
|
+
* not in the AbiArgType set today (the recogniser's `argsMatchAbi`
|
|
181
|
+
* helper rejects `other` deliberately so a close-but-wrong call does
|
|
182
|
+
* not slip through). Excluding `add_context_rule` is the right
|
|
183
|
+
* trade-off for now - it never blocks OZ smart-account recognition,
|
|
184
|
+
* because the install tx always follows a recognised
|
|
185
|
+
* `batch_add_signer` (the demo's first-pass tx), so the
|
|
186
|
+
* contract still ends up in `knownContracts`. Updating the Arg type
|
|
187
|
+
* vocabulary to cover these shapes is a separate concern (and would
|
|
188
|
+
* also lift the SEP-41 / Blend recognition in the same change).
|
|
189
|
+
*/
|
|
190
|
+
export const OZ_ACCOUNT_ABI = {
|
|
191
|
+
batch_add_signer: {
|
|
192
|
+
args: [
|
|
193
|
+
{
|
|
194
|
+
name: 'context_rule_id',
|
|
195
|
+
type: 'u32',
|
|
196
|
+
meaning: 'OZ context rule id this signer is added to',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'signers',
|
|
200
|
+
type: 'vec',
|
|
201
|
+
meaning: 'vec<SignerKey> signers to add (Delegated G-address or Account C-address)',
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
batch_remove_signer: {
|
|
206
|
+
args: [
|
|
207
|
+
{
|
|
208
|
+
name: 'context_rule_id',
|
|
209
|
+
type: 'u32',
|
|
210
|
+
meaning: 'OZ context rule id to remove signers from',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'signers',
|
|
214
|
+
type: 'vec',
|
|
215
|
+
meaning: 'vec<SignerKey> signers to remove from the rule',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
remove_context_rule: {
|
|
220
|
+
args: [
|
|
221
|
+
{
|
|
222
|
+
name: 'context_rule_id',
|
|
223
|
+
type: 'u32',
|
|
224
|
+
meaning: 'OZ context rule id to delete',
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
};
|
|
142
229
|
export const PROTOCOL_ABIS = {
|
|
143
230
|
sep41: SEP41_ABI,
|
|
144
231
|
blend: BLEND_ABI,
|
|
145
232
|
soroswap: SOROSWAP_ABI,
|
|
233
|
+
oz_account: OZ_ACCOUNT_ABI,
|
|
146
234
|
};
|
|
147
235
|
export function getAbi(protocol) {
|
|
148
236
|
return PROTOCOL_ABIS[protocol];
|
|
@@ -12,3 +12,16 @@ export interface ReviewCardSummary {
|
|
|
12
12
|
/** Build a deterministic review-card summary from a policy + context rule +
|
|
13
13
|
* simulation result. Pure: same inputs -> byte-identical output. */
|
|
14
14
|
export declare function buildReviewCardSummary(predicate: PredicateNode | null, policyRefs: PolicyRef[], contextRule: ContextRuleDraft, simulation: SimulationResult): ReviewCardSummary;
|
|
15
|
+
/** Walk every comparison / membership node of the predicate and invoke
|
|
16
|
+
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
17
|
+
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
18
|
+
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
19
|
+
/** Every constraint sentence for one predicate, in walk order.
|
|
20
|
+
*
|
|
21
|
+
* `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
|
|
22
|
+
* and needs the refs / context rule / simulation to do it. This renders a
|
|
23
|
+
* predicate on its own, which is what a caller holding a policy already
|
|
24
|
+
* INSTALLED on chain has: it can read the document out of the interpreter's
|
|
25
|
+
* storage and decode it, but there is no proposal around it. Same renderers,
|
|
26
|
+
* so an installed rule reads exactly as it did on the review card. */
|
|
27
|
+
export declare function describePredicate(predicate: PredicateNode): string[];
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// 2. The interpreter `PredicateNode`. One string per constraint leaf,
|
|
18
18
|
// rendered by enclosing-comparison kind. Templates (Task 7b):
|
|
19
|
-
// - invocation_count_in_window
|
|
19
|
+
// - invocation_count_in_window < N -> At most N calls per <window> seconds
|
|
20
20
|
// - call_arg[i] in [list] -> Recipient/arg must be one of [list]
|
|
21
21
|
// - eq(call_arg[i], literal_vec[...]) -> Path must be exactly [list]
|
|
22
22
|
// - oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
@@ -81,6 +81,23 @@ function renderOzPrimitive(ref) {
|
|
|
81
81
|
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
82
82
|
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
83
83
|
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
84
|
+
/** Every constraint sentence for one predicate, in walk order.
|
|
85
|
+
*
|
|
86
|
+
* `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
|
|
87
|
+
* and needs the refs / context rule / simulation to do it. This renders a
|
|
88
|
+
* predicate on its own, which is what a caller holding a policy already
|
|
89
|
+
* INSTALLED on chain has: it can read the document out of the interpreter's
|
|
90
|
+
* storage and decode it, but there is no proposal around it. Same renderers,
|
|
91
|
+
* so an installed rule reads exactly as it did on the review card. */
|
|
92
|
+
export function describePredicate(predicate) {
|
|
93
|
+
const constraints = [];
|
|
94
|
+
walkPredicate(predicate, (node) => {
|
|
95
|
+
const line = renderConstraint(node);
|
|
96
|
+
if (line !== null)
|
|
97
|
+
constraints.push(line);
|
|
98
|
+
});
|
|
99
|
+
return constraints;
|
|
100
|
+
}
|
|
84
101
|
function walkPredicate(node, visit) {
|
|
85
102
|
switch (node.op) {
|
|
86
103
|
case 'and':
|
|
@@ -138,17 +155,50 @@ function renderComparison(node) {
|
|
|
138
155
|
if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
|
|
139
156
|
return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
140
157
|
}
|
|
141
|
-
//
|
|
158
|
+
// eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
|
|
159
|
+
// Binds the OUTER vec length so a caller cannot append an element to
|
|
160
|
+
// defeat a per-element pin. Rendered as a single readable line so the
|
|
161
|
+
// review card surfaces the implicit "no new elements" guarantee.
|
|
162
|
+
if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
|
|
163
|
+
return `Length of arg[${left.index}] is ${right.value}`;
|
|
164
|
+
}
|
|
165
|
+
// eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
|
|
166
|
+
// The structured-argument bind pins a scalar field inside a vec element.
|
|
167
|
+
// For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
|
|
168
|
+
// is visible; eq reads "= <value>".
|
|
169
|
+
if (left.kind === 'call_arg_field') {
|
|
170
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`;
|
|
171
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
172
|
+
if (right.kind === 'literal_address')
|
|
173
|
+
return `${head} ${sep} ${right.value}`;
|
|
174
|
+
if (right.kind === 'literal_symbol')
|
|
175
|
+
return `${head} ${sep} ${right.value}`;
|
|
176
|
+
if (right.kind === 'literal_bytes')
|
|
177
|
+
return `${head} ${sep} ${right.value}`;
|
|
178
|
+
if (right.kind === 'literal_u64')
|
|
179
|
+
return `${head} ${sep} ${right.value}`;
|
|
180
|
+
if (right.kind === 'literal_i128')
|
|
181
|
+
return `${head} ${sep} ${right.value}`;
|
|
182
|
+
if (right.kind === 'literal_u32')
|
|
183
|
+
return `${head} ${sep} ${right.value}`;
|
|
184
|
+
if (right.kind === 'literal_vec') {
|
|
185
|
+
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// The bound is compared against the calls ALREADY made in the window, so
|
|
189
|
+
// `< N` permits N of them and `<= N` permits one more. Report how many
|
|
190
|
+
// calls the rule allows rather than restating the comparison.
|
|
142
191
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
143
|
-
|
|
192
|
+
const allowed = node.op === 'lt' ? right.value : right.value + 1;
|
|
193
|
+
return `At most ${allowed} calls per ${left.windowSecs} seconds`;
|
|
144
194
|
}
|
|
145
195
|
// amount <= v -> Amount <= v
|
|
146
196
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
147
197
|
return `Amount <= ${right.value}`;
|
|
148
198
|
}
|
|
149
199
|
// oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
150
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
151
|
-
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`;
|
|
200
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
201
|
+
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value} (${right.decimals} dp)`;
|
|
152
202
|
}
|
|
153
203
|
// Any other comparison shape is a structural fail-closed: do not surface
|
|
154
204
|
// a misleading line. Cross-check still requires every leaf produce a
|
|
@@ -186,6 +236,7 @@ function renderVecElement(leaf) {
|
|
|
186
236
|
case 'call_arg':
|
|
187
237
|
case 'call_arg_len':
|
|
188
238
|
case 'call_arg_field':
|
|
239
|
+
case 'call_arg_scaled':
|
|
189
240
|
case 'amount':
|
|
190
241
|
case 'window_spent':
|
|
191
242
|
case 'now':
|
|
@@ -193,6 +244,11 @@ function renderVecElement(leaf) {
|
|
|
193
244
|
case 'invocation_count_in_window':
|
|
194
245
|
case 'oracle_price':
|
|
195
246
|
return `<${leaf.kind}>`;
|
|
247
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
248
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
249
|
+
// card is where a human has to be able to see it.
|
|
250
|
+
case 'oracle_threshold':
|
|
251
|
+
return `${leaf.value} (${leaf.decimals} dp)`;
|
|
196
252
|
}
|
|
197
253
|
}
|
|
198
254
|
function renderHaystackElement(leaf) {
|
|
@@ -67,16 +67,57 @@ function pushComparison(left, right, op, out) {
|
|
|
67
67
|
out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
|
+
if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
|
|
71
|
+
out.push(`Length of arg[${left.index}] is ${right.value}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// call_arg_field: must mirror the builder's templates byte-for-byte so
|
|
75
|
+
// the cross-check can assert a faithful summary.
|
|
76
|
+
if (left.kind === 'call_arg_field') {
|
|
77
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`;
|
|
78
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
79
|
+
if (right.kind === 'literal_address') {
|
|
80
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (right.kind === 'literal_symbol') {
|
|
84
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (right.kind === 'literal_bytes') {
|
|
88
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (right.kind === 'literal_u64') {
|
|
92
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (right.kind === 'literal_i128') {
|
|
96
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (right.kind === 'literal_u32') {
|
|
100
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (right.kind === 'literal_vec') {
|
|
104
|
+
out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Mirrors the builder's wording byte-for-byte: how many calls the rule
|
|
109
|
+
// permits, since the bound counts the calls already made.
|
|
70
110
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
71
|
-
|
|
111
|
+
const allowed = op === 'lt' ? right.value : right.value + 1;
|
|
112
|
+
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
|
|
72
113
|
return;
|
|
73
114
|
}
|
|
74
115
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
75
116
|
out.push(`Amount <= ${right.value}`);
|
|
76
117
|
return;
|
|
77
118
|
}
|
|
78
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
79
|
-
out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`);
|
|
119
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
120
|
+
out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`);
|
|
80
121
|
return;
|
|
81
122
|
}
|
|
82
123
|
}
|
|
@@ -107,6 +148,7 @@ function renderVecElement(leaf) {
|
|
|
107
148
|
case 'call_arg':
|
|
108
149
|
case 'call_arg_len':
|
|
109
150
|
case 'call_arg_field':
|
|
151
|
+
case 'call_arg_scaled':
|
|
110
152
|
case 'amount':
|
|
111
153
|
case 'window_spent':
|
|
112
154
|
case 'now':
|
|
@@ -114,6 +156,11 @@ function renderVecElement(leaf) {
|
|
|
114
156
|
case 'invocation_count_in_window':
|
|
115
157
|
case 'oracle_price':
|
|
116
158
|
return `<${leaf.kind}>`;
|
|
159
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
160
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
161
|
+
// card is where a human has to be able to see it.
|
|
162
|
+
case 'oracle_threshold':
|
|
163
|
+
return `${leaf.value} (${leaf.decimals} dp)`;
|
|
117
164
|
}
|
|
118
165
|
}
|
|
119
166
|
function renderHaystackElement(leaf) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { buildReviewCardSummary, type ReviewCardSummary, } from './builder.ts';
|
|
1
|
+
export { buildReviewCardSummary, describePredicate, type ReviewCardSummary, } from './builder.ts';
|
|
2
2
|
export { type ConflictAnnotation, classifyConflict, type RuleRef, } from './conflict.ts';
|
|
3
3
|
export { summaryCrossCheck } from './cross-check.ts';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// src/review-card/index.ts - re-export the deterministic review-card surface.
|
|
2
|
-
export { buildReviewCardSummary, } from "./builder.js";
|
|
2
|
+
export { buildReviewCardSummary, describePredicate, } from "./builder.js";
|
|
3
3
|
export { classifyConflict, } from "./conflict.js";
|
|
4
4
|
export { summaryCrossCheck } from "./cross-check.js";
|
package/dist/run/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
|
|
1
|
+
import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
|
|
2
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
2
3
|
import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
|
|
3
4
|
export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
|
|
4
5
|
export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
|
|
@@ -28,7 +29,12 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
|
|
|
28
29
|
* `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
|
|
29
30
|
* XDR decode error in the adapter) would otherwise surface as
|
|
30
31
|
* "[object Object]" in the MCP transport. */
|
|
31
|
-
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy
|
|
32
|
+
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy> & {
|
|
33
|
+
explain?: {
|
|
34
|
+
predicateTree: PredicateNode | null;
|
|
35
|
+
simulation: SimulationResult;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
32
38
|
/** Build a canonical ToolError for a thrown exception caught by the tool
|
|
33
39
|
* envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
|
|
34
40
|
* default, so we extract a string-friendly message and tag the original
|
package/dist/run/index.js
CHANGED
|
@@ -89,7 +89,7 @@ export async function runSynthesizePolicy(raw) {
|
|
|
89
89
|
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
90
90
|
// exact-optional MandateSpec rejects; the schema already validated the
|
|
91
91
|
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
92
|
-
return await synthesizeFromMandate(input.mandate, ozConfig);
|
|
92
|
+
return await synthesizeFromMandate(input.mandate, ozConfig, input.explain === true ? { explain: true } : {});
|
|
93
93
|
}
|
|
94
94
|
// recording source
|
|
95
95
|
const recorded = input.recordedTx;
|
|
@@ -100,6 +100,7 @@ export async function runSynthesizePolicy(raw) {
|
|
|
100
100
|
? { confidenceOverride: input.confidenceOverride }
|
|
101
101
|
: {}),
|
|
102
102
|
...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
|
|
103
|
+
...(input.explain === true ? { explain: true } : {}),
|
|
103
104
|
}, ozConfig);
|
|
104
105
|
}
|
|
105
106
|
catch (e) {
|
package/dist/run/schemas.d.ts
CHANGED
|
@@ -648,6 +648,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
648
648
|
weighted_threshold: string;
|
|
649
649
|
};
|
|
650
650
|
}>>;
|
|
651
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
651
652
|
}, "strip", z.ZodTypeAny, {
|
|
652
653
|
source: "mandate";
|
|
653
654
|
mandate: {
|
|
@@ -676,6 +677,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
676
677
|
weighted_threshold: string;
|
|
677
678
|
};
|
|
678
679
|
} | undefined;
|
|
680
|
+
explain?: boolean | undefined;
|
|
679
681
|
}, {
|
|
680
682
|
source: "mandate";
|
|
681
683
|
mandate: {
|
|
@@ -704,6 +706,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
704
706
|
weighted_threshold: string;
|
|
705
707
|
};
|
|
706
708
|
} | undefined;
|
|
709
|
+
explain?: boolean | undefined;
|
|
707
710
|
}>;
|
|
708
711
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
709
712
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -1085,17 +1088,18 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1085
1088
|
weighted_threshold: string;
|
|
1086
1089
|
};
|
|
1087
1090
|
}>>;
|
|
1091
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1088
1092
|
}, "strip", z.ZodTypeAny, {
|
|
1089
1093
|
network: "mainnet" | "testnet";
|
|
1090
1094
|
source: "recording";
|
|
1091
1095
|
recordedTx: {
|
|
1096
|
+
signers: string[];
|
|
1092
1097
|
events: {
|
|
1093
1098
|
contract: string;
|
|
1094
1099
|
topics: string[];
|
|
1095
1100
|
data?: unknown;
|
|
1096
1101
|
}[];
|
|
1097
1102
|
network: "mainnet" | "testnet";
|
|
1098
|
-
signers: string[];
|
|
1099
1103
|
invocations: unknown[];
|
|
1100
1104
|
tokenMovements: {
|
|
1101
1105
|
amount: string;
|
|
@@ -1149,17 +1153,18 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1149
1153
|
weighted_threshold: string;
|
|
1150
1154
|
};
|
|
1151
1155
|
} | undefined;
|
|
1156
|
+
explain?: boolean | undefined;
|
|
1152
1157
|
}, {
|
|
1153
1158
|
network: "mainnet" | "testnet";
|
|
1154
1159
|
source: "recording";
|
|
1155
1160
|
recordedTx: {
|
|
1161
|
+
signers: string[];
|
|
1156
1162
|
events: {
|
|
1157
1163
|
contract: string;
|
|
1158
1164
|
topics: string[];
|
|
1159
1165
|
data?: unknown;
|
|
1160
1166
|
}[];
|
|
1161
1167
|
network: "mainnet" | "testnet";
|
|
1162
|
-
signers: string[];
|
|
1163
1168
|
invocations: unknown[];
|
|
1164
1169
|
tokenMovements: {
|
|
1165
1170
|
amount: string;
|
|
@@ -1213,6 +1218,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1213
1218
|
weighted_threshold: string;
|
|
1214
1219
|
};
|
|
1215
1220
|
} | undefined;
|
|
1221
|
+
explain?: boolean | undefined;
|
|
1216
1222
|
}>;
|
|
1217
1223
|
export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"source", [z.ZodObject<{
|
|
1218
1224
|
source: z.ZodLiteral<"mandate">;
|
|
@@ -1334,6 +1340,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1334
1340
|
weighted_threshold: string;
|
|
1335
1341
|
};
|
|
1336
1342
|
}>>;
|
|
1343
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1337
1344
|
}, "strip", z.ZodTypeAny, {
|
|
1338
1345
|
source: "mandate";
|
|
1339
1346
|
mandate: {
|
|
@@ -1362,6 +1369,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1362
1369
|
weighted_threshold: string;
|
|
1363
1370
|
};
|
|
1364
1371
|
} | undefined;
|
|
1372
|
+
explain?: boolean | undefined;
|
|
1365
1373
|
}, {
|
|
1366
1374
|
source: "mandate";
|
|
1367
1375
|
mandate: {
|
|
@@ -1390,6 +1398,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1390
1398
|
weighted_threshold: string;
|
|
1391
1399
|
};
|
|
1392
1400
|
} | undefined;
|
|
1401
|
+
explain?: boolean | undefined;
|
|
1393
1402
|
}>, z.ZodObject<{
|
|
1394
1403
|
source: z.ZodLiteral<"recording">;
|
|
1395
1404
|
recordedTx: z.ZodObject<{
|
|
@@ -1736,17 +1745,18 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1736
1745
|
weighted_threshold: string;
|
|
1737
1746
|
};
|
|
1738
1747
|
}>>;
|
|
1748
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1739
1749
|
}, "strip", z.ZodTypeAny, {
|
|
1740
1750
|
network: "mainnet" | "testnet";
|
|
1741
1751
|
source: "recording";
|
|
1742
1752
|
recordedTx: {
|
|
1753
|
+
signers: string[];
|
|
1743
1754
|
events: {
|
|
1744
1755
|
contract: string;
|
|
1745
1756
|
topics: string[];
|
|
1746
1757
|
data?: unknown;
|
|
1747
1758
|
}[];
|
|
1748
1759
|
network: "mainnet" | "testnet";
|
|
1749
|
-
signers: string[];
|
|
1750
1760
|
invocations: unknown[];
|
|
1751
1761
|
tokenMovements: {
|
|
1752
1762
|
amount: string;
|
|
@@ -1800,17 +1810,18 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1800
1810
|
weighted_threshold: string;
|
|
1801
1811
|
};
|
|
1802
1812
|
} | undefined;
|
|
1813
|
+
explain?: boolean | undefined;
|
|
1803
1814
|
}, {
|
|
1804
1815
|
network: "mainnet" | "testnet";
|
|
1805
1816
|
source: "recording";
|
|
1806
1817
|
recordedTx: {
|
|
1818
|
+
signers: string[];
|
|
1807
1819
|
events: {
|
|
1808
1820
|
contract: string;
|
|
1809
1821
|
topics: string[];
|
|
1810
1822
|
data?: unknown;
|
|
1811
1823
|
}[];
|
|
1812
1824
|
network: "mainnet" | "testnet";
|
|
1813
|
-
signers: string[];
|
|
1814
1825
|
invocations: unknown[];
|
|
1815
1826
|
tokenMovements: {
|
|
1816
1827
|
amount: string;
|
|
@@ -1864,6 +1875,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1864
1875
|
weighted_threshold: string;
|
|
1865
1876
|
};
|
|
1866
1877
|
} | undefined;
|
|
1878
|
+
explain?: boolean | undefined;
|
|
1867
1879
|
}>]>;
|
|
1868
1880
|
export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>;
|
|
1869
1881
|
export declare const ToolErrorSchema: z.ZodObject<{
|
package/dist/run/schemas.js
CHANGED
|
@@ -175,6 +175,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
|
|
|
175
175
|
source: z.literal('mandate'),
|
|
176
176
|
mandate: MandateSpecSchema,
|
|
177
177
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
178
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
179
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
180
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
181
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
182
|
+
explain: z.boolean().optional(),
|
|
178
183
|
});
|
|
179
184
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
180
185
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -200,6 +205,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
|
|
|
200
205
|
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
201
206
|
interpreter: InterpreterOptionsSchema.optional(),
|
|
202
207
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
208
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
209
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
210
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
211
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
212
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
213
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
214
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
215
|
+
// explain.
|
|
216
|
+
explain: z.boolean().optional(),
|
|
203
217
|
});
|
|
204
218
|
export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
205
219
|
SynthesizePolicyMandateInputSchema,
|
|
@@ -8,6 +8,10 @@ export interface OraclePriceBound {
|
|
|
8
8
|
asset: string;
|
|
9
9
|
operator: IRCompOp;
|
|
10
10
|
value: string;
|
|
11
|
+
/** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
|
|
12
|
+
* 9 dp, and a threshold silently assumed to share that basis is what let a
|
|
13
|
+
* raw 14-dp bound permit everything. The author states it; we convert. */
|
|
14
|
+
decimals: number;
|
|
11
15
|
}
|
|
12
16
|
/** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
|
|
13
17
|
* synth might apply must come from here - the recording supplies observed
|
|
@@ -105,11 +105,17 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
105
105
|
value: limit,
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
// A rolling spend cap always goes to OZ, whose `spending_limit` is the
|
|
109
|
+
// audited implementation. The interpreter is NOT a fallback for the
|
|
110
|
+
// token != scopeContract case: on chain it sees one authorized call,
|
|
111
|
+
// not the transaction's token movements, so it has no per-call amount
|
|
112
|
+
// to accumulate and the counter would never move. OZ reports the case
|
|
113
|
+
// it cannot cover (it pins the limit to the context contract), which
|
|
114
|
+
// is the honest outcome - the old fallback produced an interpreter
|
|
115
|
+
// predicate that silently never bound.
|
|
116
|
+
ozConstraints.push(spendCond);
|
|
117
|
+
if (interpreterEnabled && token !== scopeContract) {
|
|
118
|
+
warnings.push(`rolling spend cap on ${token} cannot be enforced on chain: OZ spending_limit pins the limit to the context contract, and the interpreter cannot observe token movements. Bound the per-call value with an argument cap plus an invocation-count limit instead.`);
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
}
|
|
@@ -134,7 +140,10 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
134
140
|
op: 'compare',
|
|
135
141
|
compare: {
|
|
136
142
|
selector: { kind: 'invocation_count', windowSeconds },
|
|
137
|
-
|
|
143
|
+
// `lt`, not `lte`: the leaf reports the calls ALREADY made in the
|
|
144
|
+
// window, so `< N` is what permits N of them. With `lte` a limit
|
|
145
|
+
// of N let an N+1th call through.
|
|
146
|
+
operator: 'lt',
|
|
138
147
|
value: String(invocationLimit),
|
|
139
148
|
},
|
|
140
149
|
};
|
|
@@ -165,6 +174,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
165
174
|
selector: { kind: 'oracle_price', asset: b.asset },
|
|
166
175
|
operator: b.operator,
|
|
167
176
|
value: b.value,
|
|
177
|
+
valueDecimals: b.decimals,
|
|
168
178
|
},
|
|
169
179
|
};
|
|
170
180
|
if (interpreterEnabled) {
|
|
@@ -300,8 +310,15 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
300
310
|
});
|
|
301
311
|
for (let i = 0; i < elements.length; i++) {
|
|
302
312
|
const element = elements[i];
|
|
303
|
-
|
|
313
|
+
// An element we cannot bind leaves that request's action, asset and
|
|
314
|
+
// amount free while the length pin makes the policy LOOK total. The
|
|
315
|
+
// length pin still stays (it is a real restriction, and dropping it
|
|
316
|
+
// would only widen the policy) - what must not happen is dropping the
|
|
317
|
+
// element quietly.
|
|
318
|
+
if (element?.type !== 'map') {
|
|
319
|
+
warnings.push(`Blend submit requests element ${i} is not a Request map (recorded as ${element?.type ?? 'nothing'}): its request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`);
|
|
304
320
|
continue;
|
|
321
|
+
}
|
|
305
322
|
const fields = element.value;
|
|
306
323
|
const fieldValue = (name, scalarType) => {
|
|
307
324
|
const entry = fields.find((e) => e.key === name);
|
|
@@ -318,8 +335,17 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
318
335
|
const address = fieldValue('address', 'address');
|
|
319
336
|
const amount = fieldValue('amount', 'i128');
|
|
320
337
|
const requestType = fieldValue('request_type', 'u32');
|
|
321
|
-
if (address === null || amount === null || requestType === null)
|
|
338
|
+
if (address === null || amount === null || requestType === null) {
|
|
339
|
+
const missing = [
|
|
340
|
+
requestType === null ? 'request_type (u32)' : null,
|
|
341
|
+
address === null ? 'address' : null,
|
|
342
|
+
amount === null ? 'amount (i128)' : null,
|
|
343
|
+
].filter((f) => f !== null);
|
|
344
|
+
// Partial pins are deliberately NOT emitted (see the note above): a
|
|
345
|
+
// half-pinned request reads as a bound one on the review card.
|
|
346
|
+
warnings.push(`Blend submit requests element ${i} could not be bound: ${missing.join(', ')} missing or of an unexpected type. That request's request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`);
|
|
322
347
|
continue;
|
|
348
|
+
}
|
|
323
349
|
interpreterConstraints.push({
|
|
324
350
|
op: 'compare',
|
|
325
351
|
compare: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PredicateNode } from '../types.ts';
|
|
1
|
+
import type { PredicateNode, ScVal } from '../types.ts';
|
|
2
2
|
import type { EvalContext } from './evaluate.ts';
|
|
3
3
|
export interface DenyCase {
|
|
4
4
|
dimension: string;
|
|
@@ -20,3 +20,6 @@ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
|
|
|
20
20
|
* are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
|
|
21
21
|
* existing fixtures do not regress. */
|
|
22
22
|
export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
|
|
23
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
24
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
25
|
+
export declare function cloneScVal(value: ScVal, depth?: number): ScVal;
|
package/dist/synth/deny-cases.js
CHANGED
|
@@ -476,7 +476,9 @@ function cloneContext(ctx) {
|
|
|
476
476
|
cloned.signerWeights = { ...ctx.signerWeights };
|
|
477
477
|
return cloned;
|
|
478
478
|
}
|
|
479
|
-
|
|
479
|
+
/** Deep-copy an ScVal so a mutation cannot alias the recorded call.
|
|
480
|
+
* Exported so the permit-context builder shares this one implementation. */
|
|
481
|
+
export function cloneScVal(value, depth = 0) {
|
|
480
482
|
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
481
483
|
// payload cannot RangeError the JS stack during deny-case mutation. Over-depth
|
|
482
484
|
// throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
|