@crediolabs/policy-synth 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/install/build-add-context-rule.js +48 -16
- package/dist/install/build-install-policy.d.ts +21 -0
- package/dist/install/build-install-policy.js +41 -3
- package/dist/predicate/encode.d.ts +12 -0
- package/dist/predicate/encode.js +5 -1
- package/dist/run/index.d.ts +14 -5
- package/dist/run/index.js +263 -14
- package/dist/run/schemas.d.ts +2277 -474
- package/dist/run/schemas.js +173 -14
- package/dist/synth/lower.d.ts +6 -2
- package/dist/synth/lower.js +21 -8
- package/dist/synth/synthesize-from-recording.js +1 -1
- package/dist/types.d.ts +18 -1
- package/dist-cjs/install/build-add-context-rule.js +48 -16
- package/dist-cjs/install/build-install-policy.d.ts +21 -0
- package/dist-cjs/install/build-install-policy.js +42 -3
- package/dist-cjs/predicate/encode.d.ts +12 -0
- package/dist-cjs/predicate/encode.js +5 -0
- package/dist-cjs/run/index.d.ts +14 -5
- package/dist-cjs/run/index.js +263 -13
- package/dist-cjs/run/schemas.d.ts +2277 -474
- package/dist-cjs/run/schemas.js +174 -15
- package/dist-cjs/synth/lower.d.ts +6 -2
- package/dist-cjs/synth/lower.js +21 -8
- package/dist-cjs/synth/synthesize-from-recording.js +1 -1
- package/dist-cjs/types.d.ts +18 -1
- package/package.json +1 -1
- package/src/install/build-add-context-rule.ts +65 -21
- package/src/install/build-install-policy.ts +65 -10
- package/src/predicate/encode.ts +5 -1
- package/src/run/index.ts +280 -23
- package/src/run/schemas.ts +201 -31
- package/src/synth/lower.ts +22 -8
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +25 -6
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
// the account refuses with `Error(Auth, InvalidAction)`.
|
|
49
49
|
import { createHash } from 'node:crypto';
|
|
50
50
|
import { Address, xdr } from '@stellar/stellar-sdk';
|
|
51
|
+
import { scvI128FromDecimal } from "../predicate/encode.js";
|
|
51
52
|
import { GRAMMAR_VERSION, OZ_LIMITS, } from "../types.js";
|
|
52
53
|
export const DEFAULT_GRAMMAR_VERSION = GRAMMAR_VERSION;
|
|
53
54
|
/** The verb `add_context_rule` takes on the wire. */
|
|
@@ -121,27 +122,58 @@ function encodeSigner(s) {
|
|
|
121
122
|
function encodePoliciesMap(args) {
|
|
122
123
|
const entries = [];
|
|
123
124
|
for (const ref of args.policies) {
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
125
|
+
// Each policy carries its OWN params. This loop once applied one set of
|
|
126
|
+
// install params to every entry, which is why nothing but the interpreter
|
|
127
|
+
// could be expressed; before that it dropped other kinds in silence, so a
|
|
128
|
+
// caller attaching a cap received a rule without it and no indication.
|
|
129
|
+
// Both directions were wrong. An unknown kind still fails loudly below - a
|
|
130
|
+
// dropped policy is a missing restriction.
|
|
131
|
+
switch (ref.kind) {
|
|
132
|
+
case 'interpreter':
|
|
133
|
+
entries.push(new xdr.ScMapEntry({
|
|
134
|
+
key: Address.fromString(ref.interpreterAddress).toScVal(),
|
|
135
|
+
val: encodePolicyInstallParams(args),
|
|
136
|
+
}));
|
|
137
|
+
break;
|
|
138
|
+
case 'spending_limit':
|
|
139
|
+
entries.push(new xdr.ScMapEntry({
|
|
140
|
+
key: Address.fromString(ref.policyAddress).toScVal(),
|
|
141
|
+
val: encodeSpendingLimitParams(ref),
|
|
142
|
+
}));
|
|
143
|
+
break;
|
|
144
|
+
default: {
|
|
145
|
+
const kind = JSON.stringify(ref.kind ?? null);
|
|
146
|
+
throw limitError('INSTALL_BUILD_FAILED', `policy kind ${kind} is not supported; dropping it here would install a rule missing the restriction you asked for`);
|
|
147
|
+
}
|
|
136
148
|
}
|
|
137
|
-
entries.push(new xdr.ScMapEntry({
|
|
138
|
-
key: Address.fromString(ref.interpreterAddress).toScVal(),
|
|
139
|
-
val: encodePolicyInstallParams(args),
|
|
140
|
-
}));
|
|
141
149
|
}
|
|
142
150
|
entries.sort(sortByScValSymbolString);
|
|
143
151
|
return xdr.ScVal.scvMap(entries);
|
|
144
152
|
}
|
|
153
|
+
/** OpenZeppelin `spending_limit`'s install params: `{ period_ledgers: u32,
|
|
154
|
+
* spending_limit: i128 }`, emitted in symbol-string order. Validated here
|
|
155
|
+
* rather than left to the chain, because a rolling cap that fails at submit
|
|
156
|
+
* has already cost the caller a signature. */
|
|
157
|
+
function encodeSpendingLimitParams(ref) {
|
|
158
|
+
if (!Number.isInteger(ref.periodLedgers) || ref.periodLedgers <= 0) {
|
|
159
|
+
throw limitError('INSTALL_BUILD_FAILED', `spending_limit periodLedgers must be a positive integer - it counts LEDGERS, not seconds; got: ${ref.periodLedgers}`);
|
|
160
|
+
}
|
|
161
|
+
if (!/^[0-9]+$/.test(ref.spendingLimit) || BigInt(ref.spendingLimit) <= 0n) {
|
|
162
|
+
throw limitError('INSTALL_BUILD_FAILED', `spending_limit must be a positive integer in the token's smallest unit; got: ${ref.spendingLimit}`);
|
|
163
|
+
}
|
|
164
|
+
const entries = [
|
|
165
|
+
new xdr.ScMapEntry({
|
|
166
|
+
key: xdr.ScVal.scvSymbol('period_ledgers'),
|
|
167
|
+
val: xdr.ScVal.scvU32(ref.periodLedgers),
|
|
168
|
+
}),
|
|
169
|
+
new xdr.ScMapEntry({
|
|
170
|
+
key: xdr.ScVal.scvSymbol('spending_limit'),
|
|
171
|
+
val: scvI128FromDecimal(ref.spendingLimit),
|
|
172
|
+
}),
|
|
173
|
+
];
|
|
174
|
+
entries.sort((a, b) => sortBySymbolString(a.key(), b.key()));
|
|
175
|
+
return xdr.ScVal.scvMap(entries);
|
|
176
|
+
}
|
|
145
177
|
function encodePolicyInstallParams(args) {
|
|
146
178
|
const predicate = Buffer.from(args.encodedPredicate, 'base64');
|
|
147
179
|
const computedHash = createHash('sha256').update(predicate).digest('hex');
|
|
@@ -93,6 +93,13 @@ export interface InstallCallDescribes {
|
|
|
93
93
|
installNonce: number;
|
|
94
94
|
predicateHash: string;
|
|
95
95
|
predicateSha256OfEmbeddedBytes: string;
|
|
96
|
+
} | {
|
|
97
|
+
/** An OpenZeppelin built-in bounding the SUM across calls, which the
|
|
98
|
+
* predicate cannot: the interpreter sees one call and keeps no state. */
|
|
99
|
+
kind: 'spending_limit';
|
|
100
|
+
address: string;
|
|
101
|
+
periodLedgers: number;
|
|
102
|
+
spendingLimit: string;
|
|
96
103
|
}>;
|
|
97
104
|
/** The install nonce, decoded from the interpreter policy's
|
|
98
105
|
* `install_nonce` field. Echoed at the top level for reviewer convenience;
|
|
@@ -172,3 +179,17 @@ export interface BuildRevokePolicyResult {
|
|
|
172
179
|
authValidUntilLedger: number;
|
|
173
180
|
rootInvocationXdr: string;
|
|
174
181
|
}
|
|
182
|
+
/** The actionable half of a failed simulation, with the transport half left out.
|
|
183
|
+
*
|
|
184
|
+
* `sim.error` names both why the chain refused the call and which host was
|
|
185
|
+
* asked, and the second half must not reach a caller. So we return only
|
|
186
|
+
* Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
|
|
187
|
+
* are what tells an operator whether the source account lacks authority, a
|
|
188
|
+
* nonce is stale, or a predicate refused. Without them "simulateTransaction
|
|
189
|
+
* failed" names nothing a caller can act on.
|
|
190
|
+
*
|
|
191
|
+
* Returns "" when the error carries no such form, so the caller keeps its short
|
|
192
|
+
* stable message rather than gaining an empty parenthesis. */
|
|
193
|
+
export declare function simulationReason(sim: {
|
|
194
|
+
error?: string;
|
|
195
|
+
}): string;
|
|
@@ -131,6 +131,21 @@ export async function buildRevokePolicyXdr(args) {
|
|
|
131
131
|
/** ~25 minutes at 5s/ledger. */
|
|
132
132
|
const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
|
|
133
133
|
// ---- internals ----
|
|
134
|
+
/** The actionable half of a failed simulation, with the transport half left out.
|
|
135
|
+
*
|
|
136
|
+
* `sim.error` names both why the chain refused the call and which host was
|
|
137
|
+
* asked, and the second half must not reach a caller. So we return only
|
|
138
|
+
* Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
|
|
139
|
+
* are what tells an operator whether the source account lacks authority, a
|
|
140
|
+
* nonce is stale, or a predicate refused. Without them "simulateTransaction
|
|
141
|
+
* failed" names nothing a caller can act on.
|
|
142
|
+
*
|
|
143
|
+
* Returns "" when the error carries no such form, so the caller keeps its short
|
|
144
|
+
* stable message rather than gaining an empty parenthesis. */
|
|
145
|
+
export function simulationReason(sim) {
|
|
146
|
+
const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])];
|
|
147
|
+
return codes.length > 0 ? ` (${codes.join(', ')})` : '';
|
|
148
|
+
}
|
|
134
149
|
/** Record a bare call to the smart account, attach the deploy-time admin rule's
|
|
135
150
|
* auth entries, and re-simulate to assemble the footprint.
|
|
136
151
|
*
|
|
@@ -160,8 +175,10 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
|
|
|
160
175
|
// Short, stable reason. The full `simulateTransaction` error (which
|
|
161
176
|
// carries host + URL detail) stays in the SDK's own logs - never
|
|
162
177
|
// reflected back into a user-facing message where it would
|
|
163
|
-
// reconnoitre the RPC.
|
|
164
|
-
|
|
178
|
+
// reconnoitre the RPC. `simulationReason` re-adds only the chain's own
|
|
179
|
+
// error codes, which say why the call was refused without saying where
|
|
180
|
+
// the RPC lives.
|
|
181
|
+
throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`);
|
|
165
182
|
}
|
|
166
183
|
const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
|
|
167
184
|
Address.fromScAddress(entry.credentials().address().address()).toString() ===
|
|
@@ -180,7 +197,7 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
|
|
|
180
197
|
const txWithAuth = buildTx(makeOperation(authEntries));
|
|
181
198
|
const enforcing = await args.rpc.simulateTransaction(txWithAuth);
|
|
182
199
|
if (rpc.Api.isSimulationError(enforcing)) {
|
|
183
|
-
throw new Error(`${errorPrefix}: auth simulateTransaction failed`);
|
|
200
|
+
throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`);
|
|
184
201
|
}
|
|
185
202
|
return {
|
|
186
203
|
finalTx: rpc.assembleTransaction(txWithAuth, enforcing).build(),
|
|
@@ -354,6 +371,27 @@ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
|
|
|
354
371
|
observedInstallNonce = installNonce;
|
|
355
372
|
continue;
|
|
356
373
|
}
|
|
374
|
+
// OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
|
|
375
|
+
if (fields.has('period_ledgers') || fields.has('spending_limit')) {
|
|
376
|
+
const periodScv = fields.get('period_ledgers');
|
|
377
|
+
if (periodScv?.switch().name !== 'scvU32') {
|
|
378
|
+
throw new Error(`install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`);
|
|
379
|
+
}
|
|
380
|
+
const limitScv = fields.get('spending_limit');
|
|
381
|
+
if (limitScv?.switch().name !== 'scvI128') {
|
|
382
|
+
throw new Error(`install_policy: spending_limit policy ${address} is missing an i128 spending_limit`);
|
|
383
|
+
}
|
|
384
|
+
const parts = limitScv.i128();
|
|
385
|
+
const spendingLimit = ((BigInt(parts.hi().toString()) << 64n) +
|
|
386
|
+
BigInt(parts.lo().toString())).toString();
|
|
387
|
+
policies.push({
|
|
388
|
+
kind: 'spending_limit',
|
|
389
|
+
address,
|
|
390
|
+
periodLedgers: periodScv.u32(),
|
|
391
|
+
spendingLimit,
|
|
392
|
+
});
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
357
395
|
throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
|
|
358
396
|
}
|
|
359
397
|
// `observedInstallNonce` is the nonce baked into whichever interpreter
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { xdr } from '@stellar/stellar-sdk';
|
|
1
2
|
import { type PredicateNode } from '../types.ts';
|
|
2
3
|
export interface EncodedPredicate {
|
|
3
4
|
/** base64 of the canonical ScVal XDR of the predicate root. */
|
|
@@ -8,3 +9,14 @@ export interface EncodedPredicate {
|
|
|
8
9
|
/** Encode a `PredicateNode` to the canonical ScVal wire format and hash it.
|
|
9
10
|
* Pure function: same input -> byte-identical output every run. */
|
|
10
11
|
export declare function encodePredicate(node: PredicateNode): EncodedPredicate;
|
|
12
|
+
/** Build `ScVal::I128(Int128Parts{hi, lo})` from a signed decimal string.
|
|
13
|
+
* `Int128Parts` encodes the value as `(hi << 64) + lo` with `hi` a SIGNED
|
|
14
|
+
* 64-bit int and `lo` an UNSIGNED 64-bit int (this is NOT signed-magnitude).
|
|
15
|
+
* The inverse split is `hi = v >> 64n` (arithmetic right shift) and
|
|
16
|
+
* `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
|
|
17
|
+
* bigint/string/number. */
|
|
18
|
+
/** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
|
|
19
|
+
* guard on the high word. Exported so the install builder encodes an
|
|
20
|
+
* OpenZeppelin amount the same way a predicate literal is encoded - a second
|
|
21
|
+
* implementation is how a value above 2^64 silently loses its high word. */
|
|
22
|
+
export declare function scvI128FromDecimal(decimal: string): xdr.ScVal;
|
package/dist/predicate/encode.js
CHANGED
|
@@ -231,7 +231,11 @@ function scvAddressFromStrkey(strkey) {
|
|
|
231
231
|
* The inverse split is `hi = v >> 64n` (arithmetic right shift) and
|
|
232
232
|
* `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
|
|
233
233
|
* bigint/string/number. */
|
|
234
|
-
|
|
234
|
+
/** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
|
|
235
|
+
* guard on the high word. Exported so the install builder encodes an
|
|
236
|
+
* OpenZeppelin amount the same way a predicate literal is encoded - a second
|
|
237
|
+
* implementation is how a value above 2^64 silently loses its high word. */
|
|
238
|
+
export function scvI128FromDecimal(decimal) {
|
|
235
239
|
const v = BigInt(decimal);
|
|
236
240
|
const hi = v >> 64n;
|
|
237
241
|
const lo = v & UINT64_MAX;
|
package/dist/run/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedT
|
|
|
2
2
|
import { type AuthorityOverlap } from '../install/authority-overlap.ts';
|
|
3
3
|
import { type BuildInstallPolicyResult, type BuildRevokePolicyResult } from '../install/build-install-policy.ts';
|
|
4
4
|
import { getInterpreterInfo } from '../install/get-interpreter-info.ts';
|
|
5
|
-
import { type RecordTransactionInput, type SimulatePolicyInput, type SynthesizePolicyInput, type VerifyPolicyInput } from './schemas.ts';
|
|
5
|
+
import { type InstallPolicyInput, type RecordTransactionInput, type SimulatePolicyInput, type SynthesizePolicyInput, type VerifyPolicyInput } from './schemas.ts';
|
|
6
6
|
export type { DeclarePolicyInput, GetInterpreterInfoInput, InstallPolicyInput, OzBuiltinPolicy, RecordTransactionInput, RevokePolicyInput, SimulatePolicyInput, SynthesizePolicyInput, VerifyPolicyInput, } from './schemas.ts';
|
|
7
7
|
export { ComposeUserResponsesSchema, DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, InterpreterOptionsSchema, MAINNET_RPC_URL, NetworkSchema, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_MAINNET_ADDRESS, PINNED_INTERPRETER_TESTNET_ADDRESS, PINNED_INTERPRETER_WASM_SHA256, PINNED_OZ_POLICY_ADDRESS_BY_NETWORK, PINNED_OZ_POLICY_WASM_SHA256, PredicateLeafSchema, PredicateNodeSchema, RecordedTransactionSchema, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SynthesizePolicyInputSchema, TESTNET_RPC_URL, ToolErrorSchema, } from './schemas.ts';
|
|
8
8
|
export type RunRecordTransactionInput = RecordTransactionInput;
|
|
@@ -51,13 +51,22 @@ export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<Bui
|
|
|
51
51
|
* policy payload, so the interpreter pin is not re-checked here.
|
|
52
52
|
* Pin selection follows `input.network` (defaults to `testnet`). */
|
|
53
53
|
export declare function runRevokePolicy(raw: unknown): Promise<ToolResponse<BuildRevokePolicyResult>>;
|
|
54
|
+
/** Scope a rule to whatever contract its predicate pins.
|
|
55
|
+
*
|
|
56
|
+
* Taking this from the predicate rather than from a separate argument means
|
|
57
|
+
* the rule's scope cannot drift from what the predicate actually checks. A
|
|
58
|
+
* predicate that pins no contract yields the default (account-wide) type,
|
|
59
|
+
* which is what an unpinned predicate means. Only the top level is walked:
|
|
60
|
+
* a contract pin nested under an `or` does not scope the rule, because the
|
|
61
|
+
* other branch would not be covered by it. */
|
|
62
|
+
export declare function contextTypeForPredicate(predicate: PredicateNode): NonNullable<InstallPolicyInput['rule']>['contextRuleType'];
|
|
54
63
|
/** `simulate_policy` body - evaluate a predicate against one recorded call.
|
|
55
64
|
*
|
|
56
65
|
* The evaluator is a second implementation of the on-chain semantics, and the
|
|
57
66
|
* conformance harness asserts it agrees with the Rust interpreter case for
|
|
58
67
|
* case. A verdict here is therefore a claim about what the contract would do,
|
|
59
68
|
* not a guess. */
|
|
60
|
-
export declare function runSimulatePolicy(raw: unknown): ToolResponse<{
|
|
69
|
+
export declare function runSimulatePolicy(raw: unknown): Promise<ToolResponse<{
|
|
61
70
|
permitted: boolean;
|
|
62
71
|
reason: string | null;
|
|
63
72
|
call: {
|
|
@@ -65,7 +74,7 @@ export declare function runSimulatePolicy(raw: unknown): ToolResponse<{
|
|
|
65
74
|
fn: string;
|
|
66
75
|
argCount: number;
|
|
67
76
|
};
|
|
68
|
-
}
|
|
77
|
+
}>>;
|
|
69
78
|
/** `declare_policy` body - the DECLARATIVE front-end.
|
|
70
79
|
*
|
|
71
80
|
* `synthesize_policy` infers a predicate from a transaction that happened;
|
|
@@ -92,7 +101,7 @@ export declare function runDeclarePolicy(raw: unknown): ToolResponse<{
|
|
|
92
101
|
* very transaction it was synthesised from. A deny case that permits means it
|
|
93
102
|
* is too LOOSE: some mutation of that transaction still gets through. `ok` is
|
|
94
103
|
* true only when neither holds. */
|
|
95
|
-
export declare function runVerifyPolicy(raw: unknown): ToolResponse<{
|
|
104
|
+
export declare function runVerifyPolicy(raw: unknown): Promise<ToolResponse<{
|
|
96
105
|
ok: boolean;
|
|
97
106
|
permit: {
|
|
98
107
|
permitted: boolean;
|
|
@@ -104,6 +113,6 @@ export declare function runVerifyPolicy(raw: unknown): ToolResponse<{
|
|
|
104
113
|
reason: string | null;
|
|
105
114
|
}>;
|
|
106
115
|
dimensionsCovered: number;
|
|
107
|
-
}
|
|
116
|
+
}>>;
|
|
108
117
|
export declare function runGetInterpreterInfo(raw: unknown): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>>;
|
|
109
118
|
export declare function caughtError(toolName: RunToolName, code: ErrorCode, e: unknown): ToolError;
|
package/dist/run/index.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// drive the CLI (which calls into the same core directly without MCP).
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
20
|
import { rpc } from '@stellar/stellar-sdk';
|
|
21
|
+
import { PLACEHOLDER_INTERPRETER_ADDRESS } from "../adapters/interpreter/adapter.js";
|
|
21
22
|
import { declarePredicate, encodePredicate, recordTransaction, synthesizeFromRecording, } from "../index.js";
|
|
22
23
|
import { findAuthorityOverlaps, } from "../install/authority-overlap.js";
|
|
23
24
|
import { buildInstallPolicyXdr, buildRevokePolicyXdr, rpcClientFromServer, } from "../install/build-install-policy.js";
|
|
@@ -25,7 +26,7 @@ import { getInterpreterInfo } from "../install/get-interpreter-info.js";
|
|
|
25
26
|
import { accountRuleReaderFromServer, collectObservedRules } from "../install/read-account-rules.js";
|
|
26
27
|
import { decodePredicate } from "../predicate/decode.js";
|
|
27
28
|
import { evaluate, generateCases } from "../simulate/index.js";
|
|
28
|
-
import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
|
|
29
|
+
import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, PINNED_OZ_POLICY_ADDRESS_BY_NETWORK, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
|
|
29
30
|
// Re-export the underlying Zod schemas so the MCP package (and any other
|
|
30
31
|
// downstream consumer) can import the canonical input shapes from the same
|
|
31
32
|
// module that owns the tool-body glue. The strict schemas are the source of
|
|
@@ -95,7 +96,24 @@ export async function runSynthesizePolicy(raw) {
|
|
|
95
96
|
}
|
|
96
97
|
const input = parsed.data;
|
|
97
98
|
try {
|
|
98
|
-
|
|
99
|
+
// `hash` is the agent-friendly alternative to `recordedTx`: re-record here
|
|
100
|
+
// rather than make the caller retype a recording it cannot copy faithfully.
|
|
101
|
+
// A recording failure is returned as-is, so the caller sees why the hash was
|
|
102
|
+
// refused instead of a synthesis error about a payload it never sent.
|
|
103
|
+
let recorded;
|
|
104
|
+
if (input.recordedTx === undefined) {
|
|
105
|
+
const rerecorded = await runRecordTransaction({
|
|
106
|
+
hash: input.transactionHash,
|
|
107
|
+
network: input.network,
|
|
108
|
+
});
|
|
109
|
+
if (!rerecorded.ok) {
|
|
110
|
+
return { ok: false, error: rerecorded.error };
|
|
111
|
+
}
|
|
112
|
+
recorded = rerecorded.data;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
recorded = input.recordedTx;
|
|
116
|
+
}
|
|
99
117
|
return await synthesizeFromRecording(recorded, {
|
|
100
118
|
network: input.network,
|
|
101
119
|
...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
|
|
@@ -117,10 +135,143 @@ export async function runInstallPolicy(raw) {
|
|
|
117
135
|
}
|
|
118
136
|
const input = parsed.data;
|
|
119
137
|
const network = input.network ?? 'testnet';
|
|
138
|
+
// `fromHash` builds the rule here rather than accepting a transcribed copy.
|
|
139
|
+
// The pinning gates below then run against the rule we just synthesized, so
|
|
140
|
+
// this path is gated identically to a caller-supplied one - it is a shortcut
|
|
141
|
+
// for the caller, never for the checks.
|
|
142
|
+
let rule = input.rule;
|
|
143
|
+
if (rule === undefined && input.fromPredicate !== undefined) {
|
|
144
|
+
const fp = input.fromPredicate;
|
|
145
|
+
let scope;
|
|
146
|
+
try {
|
|
147
|
+
scope = contextTypeForPredicate(decodePredicate(fp.encodedPredicate));
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
return toolFailure('install_policy', e);
|
|
151
|
+
}
|
|
152
|
+
rule = {
|
|
153
|
+
contextRuleType: scope,
|
|
154
|
+
name: fp.name ?? 'policy',
|
|
155
|
+
validUntilLedger: fp.validUntilLedger ?? null,
|
|
156
|
+
signers: fp.signers.map((address) => ({ kind: 'delegated', address })),
|
|
157
|
+
policies: [
|
|
158
|
+
{
|
|
159
|
+
kind: 'interpreter',
|
|
160
|
+
interpreterAddress: PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network],
|
|
161
|
+
predicateBlobBase64: fp.encodedPredicate,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (rule === undefined) {
|
|
167
|
+
// Typed rather than inline: every tool body takes `unknown`, so a
|
|
168
|
+
// misspelled key here would compile and fail only at runtime, as a
|
|
169
|
+
// validation error blamed on the caller. Naming the type restores the
|
|
170
|
+
// check on this hop.
|
|
171
|
+
const synthArgs = {
|
|
172
|
+
source: 'recording',
|
|
173
|
+
network,
|
|
174
|
+
transactionHash: input.fromHash?.transactionHash,
|
|
175
|
+
interpreter: { smartAccountAddress: input.smartAccount },
|
|
176
|
+
...(input.fromHash?.userResponses !== undefined
|
|
177
|
+
? { userResponses: input.fromHash.userResponses }
|
|
178
|
+
: {}),
|
|
179
|
+
};
|
|
180
|
+
const synthesized = await runSynthesizePolicy(synthArgs);
|
|
181
|
+
if (!synthesized.ok) {
|
|
182
|
+
return { ok: false, error: synthesized.error };
|
|
183
|
+
}
|
|
184
|
+
// The synthesizer saw a spend it could not bound. Installing anyway yields
|
|
185
|
+
// a rule that reads as a cap and enforces nothing, and nothing downstream
|
|
186
|
+
// catches it: it installs cleanly and verifies cleanly, because a missing
|
|
187
|
+
// constraint generates no deny case that could fail. That combination
|
|
188
|
+
// reached the chain once. Refuse rather than emit a warning to skim past.
|
|
189
|
+
const unbounded = synthesized.data.ambiguities.some((a) => a.code === 'AMOUNT_BOUND_MISSING');
|
|
190
|
+
if (unbounded && input.allowUnboundedAmount !== true) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: {
|
|
194
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
195
|
+
message: 'install_policy: the recorded call spends an amount this policy does not bound, so the rule would constrain everything about the call except how much it moves; set `fromHash.userResponses.limitAmount` to the per-call cap, or `allowUnboundedAmount: true` to install an unbounded rule deliberately',
|
|
196
|
+
severity: 'error',
|
|
197
|
+
retryable: false,
|
|
198
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Synthesis leaves the signer set empty - it reads a transaction, and which
|
|
203
|
+
// keys a rule binds is a security decision no single recording answers.
|
|
204
|
+
// The caller names them here.
|
|
205
|
+
rule = {
|
|
206
|
+
...synthesized.data.contextRule,
|
|
207
|
+
signers: (input.fromHash?.signers ?? []).map((address) => ({
|
|
208
|
+
kind: 'delegated',
|
|
209
|
+
address,
|
|
210
|
+
})),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// A rule that governs no key is refused on chain, and the refusal arrives as
|
|
214
|
+
// a bare contract error code with nothing to act on. Say what is missing
|
|
215
|
+
// instead, while the caller still has the recording in hand.
|
|
216
|
+
if (rule.signers.length === 0) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: {
|
|
220
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
221
|
+
message: 'install_policy: the rule names no signer, so it would govern no key; name the keys it applies to',
|
|
222
|
+
severity: 'error',
|
|
223
|
+
retryable: false,
|
|
224
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
120
228
|
// ---- Pinning gates (default-deny) ----
|
|
121
229
|
const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
|
|
122
230
|
const expectedRpc = RPC_URL_BY_NETWORK[network];
|
|
123
|
-
|
|
231
|
+
// Synthesis stamps every interpreter policy with the placeholder marker: it
|
|
232
|
+
// is handed a recording, not a network, so it emits a marker rather than
|
|
233
|
+
// inventing a deploy address. Install DOES know the network, and resolves
|
|
234
|
+
// the pin just above, so it fills the marker in here - otherwise the
|
|
235
|
+
// synthesize -> install path is unreachable, because the marker is not a
|
|
236
|
+
// strkey and fails the pin on every call. Only the exact marker is replaced;
|
|
237
|
+
// a caller-supplied address is still checked against the pin unchanged, so
|
|
238
|
+
// this widens nothing.
|
|
239
|
+
rule = {
|
|
240
|
+
...rule,
|
|
241
|
+
policies: rule.policies.map((p) => p.kind === 'interpreter' && p.interpreterAddress === PLACEHOLDER_INTERPRETER_ADDRESS
|
|
242
|
+
? { ...p, interpreterAddress: expectedInterpreter }
|
|
243
|
+
: p),
|
|
244
|
+
};
|
|
245
|
+
// A rolling total, when asked for. The predicate bounds each call; this
|
|
246
|
+
// bounds the sum across calls, which is state the interpreter does not keep.
|
|
247
|
+
// Both sit on the one rule and compose as all-of.
|
|
248
|
+
if (input.spendingLimit !== undefined) {
|
|
249
|
+
if (rule.contextRuleType.kind !== 'call_contract') {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: {
|
|
253
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
254
|
+
message: `install_policy: a spending limit meters transfers of one token, so the rule must be scoped to that token's contract; this rule's scope is "${rule.contextRuleType.kind}"`,
|
|
255
|
+
severity: 'error',
|
|
256
|
+
retryable: false,
|
|
257
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
rule = {
|
|
262
|
+
...rule,
|
|
263
|
+
policies: [
|
|
264
|
+
...rule.policies,
|
|
265
|
+
{
|
|
266
|
+
kind: 'spending_limit',
|
|
267
|
+
policyAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
268
|
+
periodLedgers: input.spendingLimit.periodLedgers,
|
|
269
|
+
spendingLimit: input.spendingLimit.amount,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const pinningError = enforceInterpreterPin(rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
|
|
124
275
|
if (pinningError) {
|
|
125
276
|
return { ok: false, error: pinningError };
|
|
126
277
|
}
|
|
@@ -136,7 +287,7 @@ export async function runInstallPolicy(raw) {
|
|
|
136
287
|
return toolFailure('install_policy', e);
|
|
137
288
|
}
|
|
138
289
|
try {
|
|
139
|
-
const interpreterPolicy =
|
|
290
|
+
const interpreterPolicy = rule.policies.find((p) => p.kind === 'interpreter');
|
|
140
291
|
const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? '';
|
|
141
292
|
const predicateHash = createHash('sha256')
|
|
142
293
|
.update(Buffer.from(encodedPredicate, 'base64'))
|
|
@@ -145,8 +296,10 @@ export async function runInstallPolicy(raw) {
|
|
|
145
296
|
smartAccount: input.smartAccount,
|
|
146
297
|
sourceAccount: input.sourceAccount,
|
|
147
298
|
networkPassphrase: NETWORK_PASSPHRASES[network],
|
|
148
|
-
rule
|
|
149
|
-
|
|
299
|
+
rule,
|
|
300
|
+
// A fresh rule has no stored nonce, so 1 is the value the interpreter
|
|
301
|
+
// expects unless the caller is deliberately re-installing.
|
|
302
|
+
installNonce: input.installNonce ?? 1,
|
|
150
303
|
encodedPredicate,
|
|
151
304
|
predicateHash,
|
|
152
305
|
rpc: rpcClient,
|
|
@@ -171,8 +324,8 @@ export async function runInstallPolicy(raw) {
|
|
|
171
324
|
// no existing rule this install replaces. A sentinel no real id
|
|
172
325
|
// can equal keeps every observed rule in scope.
|
|
173
326
|
ruleId: -1,
|
|
174
|
-
contextType:
|
|
175
|
-
signers:
|
|
327
|
+
contextType: rule.contextRuleType,
|
|
328
|
+
signers: rule.signers,
|
|
176
329
|
predicate: decodePredicate(encodedPredicate),
|
|
177
330
|
},
|
|
178
331
|
existing: observed,
|
|
@@ -266,23 +419,116 @@ function noInvocationError(toolName) {
|
|
|
266
419
|
retryable: false,
|
|
267
420
|
};
|
|
268
421
|
}
|
|
422
|
+
/** Scope a rule to whatever contract its predicate pins.
|
|
423
|
+
*
|
|
424
|
+
* Taking this from the predicate rather than from a separate argument means
|
|
425
|
+
* the rule's scope cannot drift from what the predicate actually checks. A
|
|
426
|
+
* predicate that pins no contract yields the default (account-wide) type,
|
|
427
|
+
* which is what an unpinned predicate means. Only the top level is walked:
|
|
428
|
+
* a contract pin nested under an `or` does not scope the rule, because the
|
|
429
|
+
* other branch would not be covered by it. */
|
|
430
|
+
export function contextTypeForPredicate(predicate) {
|
|
431
|
+
const conjuncts = predicate.op === 'and' ? predicate.children : [predicate];
|
|
432
|
+
for (const node of conjuncts) {
|
|
433
|
+
if (node.op !== 'eq')
|
|
434
|
+
continue;
|
|
435
|
+
if (node.left?.kind !== 'call_contract')
|
|
436
|
+
continue;
|
|
437
|
+
if (node.right?.kind !== 'literal_address')
|
|
438
|
+
continue;
|
|
439
|
+
return { kind: 'call_contract', contract: node.right.value };
|
|
440
|
+
}
|
|
441
|
+
return { kind: 'default' };
|
|
442
|
+
}
|
|
443
|
+
/** Resolve what `simulate_policy` and `verify_policy` evaluate.
|
|
444
|
+
*
|
|
445
|
+
* Both want a predicate TREE plus the recording it came from, and neither is
|
|
446
|
+
* something a caller holds by default: the tree is only returned by
|
|
447
|
+
* `synthesize_policy` under `explain`, so a caller who did not ask for it has
|
|
448
|
+
* nothing to pass and skips the check. Skipping is the worst outcome here -
|
|
449
|
+
* these two ARE the check - so a transaction hash is accepted instead and the
|
|
450
|
+
* server rebuilds both from it. Recording is deterministic for a settled
|
|
451
|
+
* transaction, so this evaluates the same predicate the synthesiser produced. */
|
|
452
|
+
async function resolveCheckInputs(input, tool) {
|
|
453
|
+
const network = input.network ?? 'testnet';
|
|
454
|
+
// The call to check against: whichever the caller supplied, recording only
|
|
455
|
+
// when they gave a hash instead.
|
|
456
|
+
let permitTx;
|
|
457
|
+
if (input.permitTx !== undefined) {
|
|
458
|
+
permitTx = input.permitTx;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
const recordArgs = { hash: input.transactionHash, network };
|
|
462
|
+
const recorded = await runRecordTransaction(recordArgs);
|
|
463
|
+
if (!recorded.ok)
|
|
464
|
+
return { ok: false, error: recorded.error };
|
|
465
|
+
permitTx = recorded.data;
|
|
466
|
+
}
|
|
467
|
+
// The thing to check. A caller-supplied predicate wins over re-synthesis,
|
|
468
|
+
// in either form: a DECLARED policy has no recording behind it, so
|
|
469
|
+
// re-deriving one from the transaction would check a different predicate
|
|
470
|
+
// than the one the caller is asking about.
|
|
471
|
+
if (input.predicate !== undefined) {
|
|
472
|
+
return { ok: true, data: { predicate: input.predicate, permitTx } };
|
|
473
|
+
}
|
|
474
|
+
if (input.encodedPredicate !== undefined) {
|
|
475
|
+
try {
|
|
476
|
+
return { ok: true, data: { predicate: decodePredicate(input.encodedPredicate), permitTx } };
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
return toolFailure(tool, e);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const synthArgs = {
|
|
483
|
+
source: 'recording',
|
|
484
|
+
network,
|
|
485
|
+
// The schema's inferred type is `passthrough`, so it carries an index
|
|
486
|
+
// signature the core type does not; the shapes agree field for field.
|
|
487
|
+
recordedTx: permitTx,
|
|
488
|
+
explain: true,
|
|
489
|
+
...(input.smartAccount !== undefined
|
|
490
|
+
? { interpreter: { smartAccountAddress: input.smartAccount } }
|
|
491
|
+
: {}),
|
|
492
|
+
...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
|
|
493
|
+
};
|
|
494
|
+
const synthesized = await runSynthesizePolicy(synthArgs);
|
|
495
|
+
if (!synthesized.ok)
|
|
496
|
+
return { ok: false, error: synthesized.error };
|
|
497
|
+
const tree = synthesized.explain?.predicateTree;
|
|
498
|
+
if (!tree) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
error: {
|
|
502
|
+
code: TOOL_ERROR_CODE[tool],
|
|
503
|
+
message: `${tool}: synthesis produced no predicate to check for that transaction`,
|
|
504
|
+
severity: 'error',
|
|
505
|
+
retryable: false,
|
|
506
|
+
remediation: { toolCall: { name: tool, args: {} } },
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
return { ok: true, data: { predicate: tree, permitTx } };
|
|
511
|
+
}
|
|
269
512
|
/** `simulate_policy` body - evaluate a predicate against one recorded call.
|
|
270
513
|
*
|
|
271
514
|
* The evaluator is a second implementation of the on-chain semantics, and the
|
|
272
515
|
* conformance harness asserts it agrees with the Rust interpreter case for
|
|
273
516
|
* case. A verdict here is therefore a claim about what the contract would do,
|
|
274
517
|
* not a guess. */
|
|
275
|
-
export function runSimulatePolicy(raw) {
|
|
518
|
+
export async function runSimulatePolicy(raw) {
|
|
276
519
|
const parsed = SimulatePolicyInputSchema.safeParse(raw);
|
|
277
520
|
if (!parsed.success) {
|
|
278
521
|
return { ok: false, error: validationError('simulate_policy', parsed.error.issues) };
|
|
279
522
|
}
|
|
280
523
|
const input = parsed.data;
|
|
281
|
-
const
|
|
524
|
+
const resolved = await resolveCheckInputs(input, 'simulate_policy');
|
|
525
|
+
if (!resolved.ok)
|
|
526
|
+
return { ok: false, error: resolved.error };
|
|
527
|
+
const ctx = evalContextFromRecording(resolved.data.permitTx);
|
|
282
528
|
if (!ctx)
|
|
283
529
|
return { ok: false, error: noInvocationError('simulate_policy') };
|
|
284
530
|
try {
|
|
285
|
-
const res = evaluate(
|
|
531
|
+
const res = evaluate(resolved.data.predicate, ctx);
|
|
286
532
|
return {
|
|
287
533
|
ok: true,
|
|
288
534
|
data: {
|
|
@@ -356,17 +602,20 @@ export function runDeclarePolicy(raw) {
|
|
|
356
602
|
* very transaction it was synthesised from. A deny case that permits means it
|
|
357
603
|
* is too LOOSE: some mutation of that transaction still gets through. `ok` is
|
|
358
604
|
* true only when neither holds. */
|
|
359
|
-
export function runVerifyPolicy(raw) {
|
|
605
|
+
export async function runVerifyPolicy(raw) {
|
|
360
606
|
const parsed = VerifyPolicyInputSchema.safeParse(raw);
|
|
361
607
|
if (!parsed.success) {
|
|
362
608
|
return { ok: false, error: validationError('verify_policy', parsed.error.issues) };
|
|
363
609
|
}
|
|
364
610
|
const input = parsed.data;
|
|
365
|
-
const
|
|
611
|
+
const resolved = await resolveCheckInputs(input, 'verify_policy');
|
|
612
|
+
if (!resolved.ok)
|
|
613
|
+
return { ok: false, error: resolved.error };
|
|
614
|
+
const ctx = evalContextFromRecording(resolved.data.permitTx);
|
|
366
615
|
if (!ctx)
|
|
367
616
|
return { ok: false, error: noInvocationError('verify_policy') };
|
|
368
617
|
try {
|
|
369
|
-
const predicate =
|
|
618
|
+
const predicate = resolved.data.predicate;
|
|
370
619
|
const cases = generateCases(predicate, ctx);
|
|
371
620
|
const permitRes = evaluate(predicate, cases.permit);
|
|
372
621
|
const denies = cases.denies.map((d) => {
|