@crediolabs/policy-synth 1.0.0 → 1.1.1
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 +41 -0
- package/dist/install/build-install-policy.js +51 -5
- 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 +2279 -476
- package/dist/run/schemas.js +192 -26
- 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 +41 -0
- package/dist-cjs/install/build-install-policy.js +52 -5
- 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 +2279 -476
- package/dist-cjs/run/schemas.js +193 -27
- 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 +100 -12
- package/src/predicate/encode.ts +5 -1
- package/src/run/index.ts +280 -23
- package/src/run/schemas.ts +229 -43
- package/src/synth/lower.ts +22 -8
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +25 -6
|
@@ -159,25 +159,63 @@ export interface InstallCallDescribes {
|
|
|
159
159
|
* sha256 of the predicate blob actually embedded in the XDR - so a
|
|
160
160
|
* mismatch between the wire bytes and the review card is detectable
|
|
161
161
|
* by reading `describes`. */
|
|
162
|
-
policies: Array<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
policies: Array<
|
|
163
|
+
| {
|
|
164
|
+
kind: 'interpreter'
|
|
165
|
+
address: string
|
|
166
|
+
installNonce: number
|
|
167
|
+
predicateHash: string
|
|
168
|
+
predicateSha256OfEmbeddedBytes: string
|
|
169
|
+
}
|
|
170
|
+
| {
|
|
171
|
+
/** An OpenZeppelin built-in bounding the SUM across calls, which the
|
|
172
|
+
* predicate cannot: the interpreter sees one call and keeps no state. */
|
|
173
|
+
kind: 'spending_limit'
|
|
174
|
+
address: string
|
|
175
|
+
periodLedgers: number
|
|
176
|
+
spendingLimit: string
|
|
177
|
+
}
|
|
178
|
+
>
|
|
169
179
|
/** The install nonce, decoded from the interpreter policy's
|
|
170
180
|
* `install_nonce` field. Echoed at the top level for reviewer convenience;
|
|
171
181
|
* the per-policy entry is the source of truth. */
|
|
172
182
|
installNonce: number
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
/** `unsignedXdr` plus the length and digest that prove it arrived whole. */
|
|
186
|
+
function xdrIntegrity(unsignedXdr: string): {
|
|
187
|
+
unsignedXdr: string
|
|
188
|
+
unsignedXdrLength: number
|
|
189
|
+
unsignedXdrSha256: string
|
|
190
|
+
} {
|
|
191
|
+
return {
|
|
192
|
+
unsignedXdr,
|
|
193
|
+
unsignedXdrLength: unsignedXdr.length,
|
|
194
|
+
unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
175
198
|
/** Output of the install-policy build. The unsigned XDR is the wallet's
|
|
176
199
|
* input; the captured auth nonce + invocation root make the response
|
|
177
200
|
* self-describing for callers that want to inspect what they signed. */
|
|
178
201
|
export interface BuildInstallPolicyResult {
|
|
179
202
|
/** Unsigned Soroban transaction envelope, base64 XDR. */
|
|
180
203
|
unsignedXdr: string
|
|
204
|
+
/** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
|
|
205
|
+
* prove it arrived whole.
|
|
206
|
+
*
|
|
207
|
+
* This envelope runs to several thousand characters, and the only route
|
|
208
|
+
* from a tool result onto disk is the caller re-emitting it. A truncated
|
|
209
|
+
* copy is not obviously wrong - it fails later as
|
|
210
|
+
* "failed to decode XDR: xdr value invalid", which reads like a malformed
|
|
211
|
+
* transaction rather than a transport problem. Observed in practice: one of
|
|
212
|
+
* two envelopes written in the same session lost its tail and its base64
|
|
213
|
+
* length went from a multiple of four to `len % 4 == 3`.
|
|
214
|
+
*
|
|
215
|
+
* Check both before signing. They are cheap, and they turn a silent,
|
|
216
|
+
* fatal truncation into a retry. */
|
|
217
|
+
unsignedXdrLength: number
|
|
218
|
+
unsignedXdrSha256: string
|
|
181
219
|
/** Smart account contract address (echo). */
|
|
182
220
|
smartAccount: string
|
|
183
221
|
/** Source account (echo) - the address that must sign. */
|
|
@@ -248,7 +286,7 @@ export async function buildInstallPolicyXdr(
|
|
|
248
286
|
const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
|
|
249
287
|
|
|
250
288
|
return {
|
|
251
|
-
|
|
289
|
+
...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
|
|
252
290
|
smartAccount: args.smartAccount,
|
|
253
291
|
sourceAccount: args.sourceAccount,
|
|
254
292
|
call: { contract: args.smartAccount, fn: 'add_context_rule' },
|
|
@@ -292,7 +330,7 @@ export async function buildRevokePolicyXdr(args: {
|
|
|
292
330
|
)
|
|
293
331
|
|
|
294
332
|
return {
|
|
295
|
-
|
|
333
|
+
...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
|
|
296
334
|
smartAccount: args.smartAccount,
|
|
297
335
|
sourceAccount: args.sourceAccount,
|
|
298
336
|
call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
|
|
@@ -304,6 +342,11 @@ export async function buildRevokePolicyXdr(args: {
|
|
|
304
342
|
|
|
305
343
|
export interface BuildRevokePolicyResult {
|
|
306
344
|
unsignedXdr: string
|
|
345
|
+
/** Same integrity pair as the install result, for the same reason: a revoke
|
|
346
|
+
* envelope also has to reach a signer intact, and a truncated copy fails as
|
|
347
|
+
* a malformed transaction rather than as a transport error. */
|
|
348
|
+
unsignedXdrLength: number
|
|
349
|
+
unsignedXdrSha256: string
|
|
307
350
|
smartAccount: string
|
|
308
351
|
sourceAccount: string
|
|
309
352
|
call: { contract: string; fn: 'remove_context_rule'; ruleId: number }
|
|
@@ -317,6 +360,22 @@ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
|
|
|
317
360
|
|
|
318
361
|
// ---- internals ----
|
|
319
362
|
|
|
363
|
+
/** The actionable half of a failed simulation, with the transport half left out.
|
|
364
|
+
*
|
|
365
|
+
* `sim.error` names both why the chain refused the call and which host was
|
|
366
|
+
* asked, and the second half must not reach a caller. So we return only
|
|
367
|
+
* Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
|
|
368
|
+
* are what tells an operator whether the source account lacks authority, a
|
|
369
|
+
* nonce is stale, or a predicate refused. Without them "simulateTransaction
|
|
370
|
+
* failed" names nothing a caller can act on.
|
|
371
|
+
*
|
|
372
|
+
* Returns "" when the error carries no such form, so the caller keeps its short
|
|
373
|
+
* stable message rather than gaining an empty parenthesis. */
|
|
374
|
+
export function simulationReason(sim: { error?: string }): string {
|
|
375
|
+
const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])]
|
|
376
|
+
return codes.length > 0 ? ` (${codes.join(', ')})` : ''
|
|
377
|
+
}
|
|
378
|
+
|
|
320
379
|
/** Record a bare call to the smart account, attach the deploy-time admin rule's
|
|
321
380
|
* auth entries, and re-simulate to assemble the footprint.
|
|
322
381
|
*
|
|
@@ -367,8 +426,10 @@ async function buildAuthorisedSmartAccountTx(
|
|
|
367
426
|
// Short, stable reason. The full `simulateTransaction` error (which
|
|
368
427
|
// carries host + URL detail) stays in the SDK's own logs - never
|
|
369
428
|
// reflected back into a user-facing message where it would
|
|
370
|
-
// reconnoitre the RPC.
|
|
371
|
-
|
|
429
|
+
// reconnoitre the RPC. `simulationReason` re-adds only the chain's own
|
|
430
|
+
// error codes, which say why the call was refused without saying where
|
|
431
|
+
// the RPC lives.
|
|
432
|
+
throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`)
|
|
372
433
|
}
|
|
373
434
|
const original = (recorded.result?.auth ?? []).find(
|
|
374
435
|
(entry) =>
|
|
@@ -406,7 +467,7 @@ async function buildAuthorisedSmartAccountTx(
|
|
|
406
467
|
const txWithAuth = buildTx(makeOperation(authEntries))
|
|
407
468
|
const enforcing = await args.rpc.simulateTransaction(txWithAuth)
|
|
408
469
|
if (rpc.Api.isSimulationError(enforcing)) {
|
|
409
|
-
throw new Error(`${errorPrefix}: auth simulateTransaction failed`)
|
|
470
|
+
throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`)
|
|
410
471
|
}
|
|
411
472
|
return {
|
|
412
473
|
finalTx: rpc.assembleTransaction(txWithAuth, enforcing).build(),
|
|
@@ -609,6 +670,33 @@ function decodeInstallCallDescribes(
|
|
|
609
670
|
observedInstallNonce = installNonce
|
|
610
671
|
continue
|
|
611
672
|
}
|
|
673
|
+
// OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
|
|
674
|
+
if (fields.has('period_ledgers') || fields.has('spending_limit')) {
|
|
675
|
+
const periodScv = fields.get('period_ledgers')
|
|
676
|
+
if (periodScv?.switch().name !== 'scvU32') {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
const limitScv = fields.get('spending_limit')
|
|
682
|
+
if (limitScv?.switch().name !== 'scvI128') {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`install_policy: spending_limit policy ${address} is missing an i128 spending_limit`
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
const parts = limitScv.i128()
|
|
688
|
+
const spendingLimit = (
|
|
689
|
+
(BigInt(parts.hi().toString()) << 64n) +
|
|
690
|
+
BigInt(parts.lo().toString())
|
|
691
|
+
).toString()
|
|
692
|
+
policies.push({
|
|
693
|
+
kind: 'spending_limit',
|
|
694
|
+
address,
|
|
695
|
+
periodLedgers: periodScv.u32(),
|
|
696
|
+
spendingLimit,
|
|
697
|
+
})
|
|
698
|
+
continue
|
|
699
|
+
}
|
|
612
700
|
throw new Error(
|
|
613
701
|
`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`
|
|
614
702
|
)
|
package/src/predicate/encode.ts
CHANGED
|
@@ -285,7 +285,11 @@ function scvAddressFromStrkey(strkey: string): xdr.ScVal {
|
|
|
285
285
|
* The inverse split is `hi = v >> 64n` (arithmetic right shift) and
|
|
286
286
|
* `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
|
|
287
287
|
* bigint/string/number. */
|
|
288
|
-
|
|
288
|
+
/** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
|
|
289
|
+
* guard on the high word. Exported so the install builder encodes an
|
|
290
|
+
* OpenZeppelin amount the same way a predicate literal is encoded - a second
|
|
291
|
+
* implementation is how a value above 2^64 silently loses its high word. */
|
|
292
|
+
export function scvI128FromDecimal(decimal: string): xdr.ScVal {
|
|
289
293
|
const v = BigInt(decimal)
|
|
290
294
|
const hi = v >> 64n
|
|
291
295
|
const lo = v & UINT64_MAX
|
package/src/run/index.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { createHash } from 'node:crypto'
|
|
21
21
|
import { rpc } from '@stellar/stellar-sdk'
|
|
22
|
+
import { PLACEHOLDER_INTERPRETER_ADDRESS } from '../adapters/interpreter/adapter.ts'
|
|
22
23
|
import {
|
|
23
24
|
declarePredicate,
|
|
24
25
|
type ErrorCode,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
|
|
61
62
|
PINNED_INTERPRETER_GRAMMAR_VERSION,
|
|
62
63
|
PINNED_INTERPRETER_WASM_SHA256,
|
|
64
|
+
PINNED_OZ_POLICY_ADDRESS_BY_NETWORK,
|
|
63
65
|
type RecordTransactionInput,
|
|
64
66
|
RecordTransactionInputSchema,
|
|
65
67
|
type RevokePolicyInput,
|
|
@@ -203,7 +205,23 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
|
203
205
|
const input = parsed.data
|
|
204
206
|
|
|
205
207
|
try {
|
|
206
|
-
|
|
208
|
+
// `hash` is the agent-friendly alternative to `recordedTx`: re-record here
|
|
209
|
+
// rather than make the caller retype a recording it cannot copy faithfully.
|
|
210
|
+
// A recording failure is returned as-is, so the caller sees why the hash was
|
|
211
|
+
// refused instead of a synthesis error about a payload it never sent.
|
|
212
|
+
let recorded: RecordedTransaction
|
|
213
|
+
if (input.recordedTx === undefined) {
|
|
214
|
+
const rerecorded = await runRecordTransaction({
|
|
215
|
+
hash: input.transactionHash,
|
|
216
|
+
network: input.network,
|
|
217
|
+
})
|
|
218
|
+
if (!rerecorded.ok) {
|
|
219
|
+
return { ok: false, error: rerecorded.error }
|
|
220
|
+
}
|
|
221
|
+
recorded = rerecorded.data
|
|
222
|
+
} else {
|
|
223
|
+
recorded = input.recordedTx as RecordedTransaction
|
|
224
|
+
}
|
|
207
225
|
return await synthesizeFromRecording(recorded, {
|
|
208
226
|
network: input.network,
|
|
209
227
|
...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
|
|
@@ -227,11 +245,147 @@ export async function runInstallPolicy(
|
|
|
227
245
|
}
|
|
228
246
|
const input: InstallPolicyInput = parsed.data
|
|
229
247
|
const network: Network = input.network ?? 'testnet'
|
|
248
|
+
// `fromHash` builds the rule here rather than accepting a transcribed copy.
|
|
249
|
+
// The pinning gates below then run against the rule we just synthesized, so
|
|
250
|
+
// this path is gated identically to a caller-supplied one - it is a shortcut
|
|
251
|
+
// for the caller, never for the checks.
|
|
252
|
+
let rule = input.rule
|
|
253
|
+
if (rule === undefined && input.fromPredicate !== undefined) {
|
|
254
|
+
const fp = input.fromPredicate
|
|
255
|
+
let scope: NonNullable<InstallPolicyInput['rule']>['contextRuleType']
|
|
256
|
+
try {
|
|
257
|
+
scope = contextTypeForPredicate(decodePredicate(fp.encodedPredicate))
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return toolFailure('install_policy', e)
|
|
260
|
+
}
|
|
261
|
+
rule = {
|
|
262
|
+
contextRuleType: scope,
|
|
263
|
+
name: fp.name ?? 'policy',
|
|
264
|
+
validUntilLedger: fp.validUntilLedger ?? null,
|
|
265
|
+
signers: fp.signers.map((address) => ({ kind: 'delegated' as const, address })),
|
|
266
|
+
policies: [
|
|
267
|
+
{
|
|
268
|
+
kind: 'interpreter' as const,
|
|
269
|
+
interpreterAddress: PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network],
|
|
270
|
+
predicateBlobBase64: fp.encodedPredicate,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (rule === undefined) {
|
|
276
|
+
// Typed rather than inline: every tool body takes `unknown`, so a
|
|
277
|
+
// misspelled key here would compile and fail only at runtime, as a
|
|
278
|
+
// validation error blamed on the caller. Naming the type restores the
|
|
279
|
+
// check on this hop.
|
|
280
|
+
const synthArgs: SynthesizePolicyInput = {
|
|
281
|
+
source: 'recording',
|
|
282
|
+
network,
|
|
283
|
+
transactionHash: input.fromHash?.transactionHash,
|
|
284
|
+
interpreter: { smartAccountAddress: input.smartAccount },
|
|
285
|
+
...(input.fromHash?.userResponses !== undefined
|
|
286
|
+
? { userResponses: input.fromHash.userResponses }
|
|
287
|
+
: {}),
|
|
288
|
+
}
|
|
289
|
+
const synthesized = await runSynthesizePolicy(synthArgs)
|
|
290
|
+
if (!synthesized.ok) {
|
|
291
|
+
return { ok: false, error: synthesized.error }
|
|
292
|
+
}
|
|
293
|
+
// The synthesizer saw a spend it could not bound. Installing anyway yields
|
|
294
|
+
// a rule that reads as a cap and enforces nothing, and nothing downstream
|
|
295
|
+
// catches it: it installs cleanly and verifies cleanly, because a missing
|
|
296
|
+
// constraint generates no deny case that could fail. That combination
|
|
297
|
+
// reached the chain once. Refuse rather than emit a warning to skim past.
|
|
298
|
+
const unbounded = synthesized.data.ambiguities.some((a) => a.code === 'AMOUNT_BOUND_MISSING')
|
|
299
|
+
if (unbounded && input.allowUnboundedAmount !== true) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
error: {
|
|
303
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
304
|
+
message:
|
|
305
|
+
'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',
|
|
306
|
+
severity: 'error',
|
|
307
|
+
retryable: false,
|
|
308
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Synthesis leaves the signer set empty - it reads a transaction, and which
|
|
313
|
+
// keys a rule binds is a security decision no single recording answers.
|
|
314
|
+
// The caller names them here.
|
|
315
|
+
rule = {
|
|
316
|
+
...synthesized.data.contextRule,
|
|
317
|
+
signers: (input.fromHash?.signers ?? []).map((address) => ({
|
|
318
|
+
kind: 'delegated' as const,
|
|
319
|
+
address,
|
|
320
|
+
})),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// A rule that governs no key is refused on chain, and the refusal arrives as
|
|
324
|
+
// a bare contract error code with nothing to act on. Say what is missing
|
|
325
|
+
// instead, while the caller still has the recording in hand.
|
|
326
|
+
if (rule.signers.length === 0) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: {
|
|
330
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
331
|
+
message:
|
|
332
|
+
'install_policy: the rule names no signer, so it would govern no key; name the keys it applies to',
|
|
333
|
+
severity: 'error',
|
|
334
|
+
retryable: false,
|
|
335
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
230
339
|
// ---- Pinning gates (default-deny) ----
|
|
231
340
|
const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
|
|
232
341
|
const expectedRpc = RPC_URL_BY_NETWORK[network]
|
|
342
|
+
// Synthesis stamps every interpreter policy with the placeholder marker: it
|
|
343
|
+
// is handed a recording, not a network, so it emits a marker rather than
|
|
344
|
+
// inventing a deploy address. Install DOES know the network, and resolves
|
|
345
|
+
// the pin just above, so it fills the marker in here - otherwise the
|
|
346
|
+
// synthesize -> install path is unreachable, because the marker is not a
|
|
347
|
+
// strkey and fails the pin on every call. Only the exact marker is replaced;
|
|
348
|
+
// a caller-supplied address is still checked against the pin unchanged, so
|
|
349
|
+
// this widens nothing.
|
|
350
|
+
rule = {
|
|
351
|
+
...rule,
|
|
352
|
+
policies: rule.policies.map((p) =>
|
|
353
|
+
p.kind === 'interpreter' && p.interpreterAddress === PLACEHOLDER_INTERPRETER_ADDRESS
|
|
354
|
+
? { ...p, interpreterAddress: expectedInterpreter }
|
|
355
|
+
: p
|
|
356
|
+
),
|
|
357
|
+
}
|
|
358
|
+
// A rolling total, when asked for. The predicate bounds each call; this
|
|
359
|
+
// bounds the sum across calls, which is state the interpreter does not keep.
|
|
360
|
+
// Both sit on the one rule and compose as all-of.
|
|
361
|
+
if (input.spendingLimit !== undefined) {
|
|
362
|
+
if (rule.contextRuleType.kind !== 'call_contract') {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: {
|
|
366
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
367
|
+
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}"`,
|
|
368
|
+
severity: 'error',
|
|
369
|
+
retryable: false,
|
|
370
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
rule = {
|
|
375
|
+
...rule,
|
|
376
|
+
policies: [
|
|
377
|
+
...rule.policies,
|
|
378
|
+
{
|
|
379
|
+
kind: 'spending_limit' as const,
|
|
380
|
+
policyAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
381
|
+
periodLedgers: input.spendingLimit.periodLedgers,
|
|
382
|
+
spendingLimit: input.spendingLimit.amount,
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
}
|
|
386
|
+
}
|
|
233
387
|
const pinningError = enforceInterpreterPin(
|
|
234
|
-
|
|
388
|
+
rule.policies,
|
|
235
389
|
input.allowUnpinnedInterpreter,
|
|
236
390
|
expectedInterpreter
|
|
237
391
|
)
|
|
@@ -255,7 +409,7 @@ export async function runInstallPolicy(
|
|
|
255
409
|
return toolFailure('install_policy', e)
|
|
256
410
|
}
|
|
257
411
|
try {
|
|
258
|
-
const interpreterPolicy =
|
|
412
|
+
const interpreterPolicy = rule.policies.find((p) => p.kind === 'interpreter')
|
|
259
413
|
const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? ''
|
|
260
414
|
const predicateHash = createHash('sha256')
|
|
261
415
|
.update(Buffer.from(encodedPredicate, 'base64'))
|
|
@@ -264,8 +418,10 @@ export async function runInstallPolicy(
|
|
|
264
418
|
smartAccount: input.smartAccount,
|
|
265
419
|
sourceAccount: input.sourceAccount,
|
|
266
420
|
networkPassphrase: NETWORK_PASSPHRASES[network],
|
|
267
|
-
rule
|
|
268
|
-
|
|
421
|
+
rule,
|
|
422
|
+
// A fresh rule has no stored nonce, so 1 is the value the interpreter
|
|
423
|
+
// expects unless the caller is deliberately re-installing.
|
|
424
|
+
installNonce: input.installNonce ?? 1,
|
|
269
425
|
encodedPredicate,
|
|
270
426
|
predicateHash,
|
|
271
427
|
rpc: rpcClient,
|
|
@@ -291,8 +447,8 @@ export async function runInstallPolicy(
|
|
|
291
447
|
// no existing rule this install replaces. A sentinel no real id
|
|
292
448
|
// can equal keeps every observed rule in scope.
|
|
293
449
|
ruleId: -1,
|
|
294
|
-
contextType:
|
|
295
|
-
signers:
|
|
450
|
+
contextType: rule.contextRuleType,
|
|
451
|
+
signers: rule.signers,
|
|
296
452
|
predicate: decodePredicate(encodedPredicate),
|
|
297
453
|
},
|
|
298
454
|
existing: observed,
|
|
@@ -394,26 +550,123 @@ function noInvocationError(toolName: 'simulate_policy' | 'verify_policy'): ToolE
|
|
|
394
550
|
}
|
|
395
551
|
}
|
|
396
552
|
|
|
553
|
+
/** Scope a rule to whatever contract its predicate pins.
|
|
554
|
+
*
|
|
555
|
+
* Taking this from the predicate rather than from a separate argument means
|
|
556
|
+
* the rule's scope cannot drift from what the predicate actually checks. A
|
|
557
|
+
* predicate that pins no contract yields the default (account-wide) type,
|
|
558
|
+
* which is what an unpinned predicate means. Only the top level is walked:
|
|
559
|
+
* a contract pin nested under an `or` does not scope the rule, because the
|
|
560
|
+
* other branch would not be covered by it. */
|
|
561
|
+
export function contextTypeForPredicate(
|
|
562
|
+
predicate: PredicateNode
|
|
563
|
+
): NonNullable<InstallPolicyInput['rule']>['contextRuleType'] {
|
|
564
|
+
const conjuncts = predicate.op === 'and' ? predicate.children : [predicate]
|
|
565
|
+
for (const node of conjuncts) {
|
|
566
|
+
if (node.op !== 'eq') continue
|
|
567
|
+
if (node.left?.kind !== 'call_contract') continue
|
|
568
|
+
if (node.right?.kind !== 'literal_address') continue
|
|
569
|
+
return { kind: 'call_contract', contract: node.right.value }
|
|
570
|
+
}
|
|
571
|
+
return { kind: 'default' }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Resolve what `simulate_policy` and `verify_policy` evaluate.
|
|
575
|
+
*
|
|
576
|
+
* Both want a predicate TREE plus the recording it came from, and neither is
|
|
577
|
+
* something a caller holds by default: the tree is only returned by
|
|
578
|
+
* `synthesize_policy` under `explain`, so a caller who did not ask for it has
|
|
579
|
+
* nothing to pass and skips the check. Skipping is the worst outcome here -
|
|
580
|
+
* these two ARE the check - so a transaction hash is accepted instead and the
|
|
581
|
+
* server rebuilds both from it. Recording is deterministic for a settled
|
|
582
|
+
* transaction, so this evaluates the same predicate the synthesiser produced. */
|
|
583
|
+
async function resolveCheckInputs(
|
|
584
|
+
input: SimulatePolicyInput,
|
|
585
|
+
tool: 'simulate_policy' | 'verify_policy'
|
|
586
|
+
): Promise<ToolResponse<{ predicate: PredicateNode; permitTx: RecordedTransaction }>> {
|
|
587
|
+
const network = input.network ?? 'testnet'
|
|
588
|
+
|
|
589
|
+
// The call to check against: whichever the caller supplied, recording only
|
|
590
|
+
// when they gave a hash instead.
|
|
591
|
+
let permitTx: RecordedTransaction
|
|
592
|
+
if (input.permitTx !== undefined) {
|
|
593
|
+
permitTx = input.permitTx as RecordedTransaction
|
|
594
|
+
} else {
|
|
595
|
+
const recordArgs: RecordTransactionInput = { hash: input.transactionHash, network }
|
|
596
|
+
const recorded = await runRecordTransaction(recordArgs)
|
|
597
|
+
if (!recorded.ok) return { ok: false, error: recorded.error }
|
|
598
|
+
permitTx = recorded.data
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// The thing to check. A caller-supplied predicate wins over re-synthesis,
|
|
602
|
+
// in either form: a DECLARED policy has no recording behind it, so
|
|
603
|
+
// re-deriving one from the transaction would check a different predicate
|
|
604
|
+
// than the one the caller is asking about.
|
|
605
|
+
if (input.predicate !== undefined) {
|
|
606
|
+
return { ok: true, data: { predicate: input.predicate as PredicateNode, permitTx } }
|
|
607
|
+
}
|
|
608
|
+
if (input.encodedPredicate !== undefined) {
|
|
609
|
+
try {
|
|
610
|
+
return { ok: true, data: { predicate: decodePredicate(input.encodedPredicate), permitTx } }
|
|
611
|
+
} catch (e) {
|
|
612
|
+
return toolFailure(tool, e)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const synthArgs: SynthesizePolicyInput = {
|
|
617
|
+
source: 'recording',
|
|
618
|
+
network,
|
|
619
|
+
// The schema's inferred type is `passthrough`, so it carries an index
|
|
620
|
+
// signature the core type does not; the shapes agree field for field.
|
|
621
|
+
recordedTx: permitTx as SynthesizePolicyInput['recordedTx'],
|
|
622
|
+
explain: true,
|
|
623
|
+
...(input.smartAccount !== undefined
|
|
624
|
+
? { interpreter: { smartAccountAddress: input.smartAccount } }
|
|
625
|
+
: {}),
|
|
626
|
+
...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
|
|
627
|
+
}
|
|
628
|
+
const synthesized = await runSynthesizePolicy(synthArgs)
|
|
629
|
+
if (!synthesized.ok) return { ok: false, error: synthesized.error }
|
|
630
|
+
const tree = synthesized.explain?.predicateTree
|
|
631
|
+
if (!tree) {
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
error: {
|
|
635
|
+
code: TOOL_ERROR_CODE[tool],
|
|
636
|
+
message: `${tool}: synthesis produced no predicate to check for that transaction`,
|
|
637
|
+
severity: 'error',
|
|
638
|
+
retryable: false,
|
|
639
|
+
remediation: { toolCall: { name: tool, args: {} } },
|
|
640
|
+
},
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return { ok: true, data: { predicate: tree, permitTx } }
|
|
644
|
+
}
|
|
645
|
+
|
|
397
646
|
/** `simulate_policy` body - evaluate a predicate against one recorded call.
|
|
398
647
|
*
|
|
399
648
|
* The evaluator is a second implementation of the on-chain semantics, and the
|
|
400
649
|
* conformance harness asserts it agrees with the Rust interpreter case for
|
|
401
650
|
* case. A verdict here is therefore a claim about what the contract would do,
|
|
402
651
|
* not a guess. */
|
|
403
|
-
export function runSimulatePolicy(raw: unknown):
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
652
|
+
export async function runSimulatePolicy(raw: unknown): Promise<
|
|
653
|
+
ToolResponse<{
|
|
654
|
+
permitted: boolean
|
|
655
|
+
reason: string | null
|
|
656
|
+
call: { contract: string; fn: string; argCount: number }
|
|
657
|
+
}>
|
|
658
|
+
> {
|
|
408
659
|
const parsed = SimulatePolicyInputSchema.safeParse(raw)
|
|
409
660
|
if (!parsed.success) {
|
|
410
661
|
return { ok: false, error: validationError('simulate_policy', parsed.error.issues) }
|
|
411
662
|
}
|
|
412
663
|
const input: SimulatePolicyInput = parsed.data
|
|
413
|
-
const
|
|
664
|
+
const resolved = await resolveCheckInputs(input, 'simulate_policy')
|
|
665
|
+
if (!resolved.ok) return { ok: false, error: resolved.error }
|
|
666
|
+
const ctx = evalContextFromRecording(resolved.data.permitTx)
|
|
414
667
|
if (!ctx) return { ok: false, error: noInvocationError('simulate_policy') }
|
|
415
668
|
try {
|
|
416
|
-
const res = evaluate(
|
|
669
|
+
const res = evaluate(resolved.data.predicate, ctx)
|
|
417
670
|
return {
|
|
418
671
|
ok: true,
|
|
419
672
|
data: {
|
|
@@ -492,21 +745,25 @@ export function runDeclarePolicy(raw: unknown): ToolResponse<{
|
|
|
492
745
|
* very transaction it was synthesised from. A deny case that permits means it
|
|
493
746
|
* is too LOOSE: some mutation of that transaction still gets through. `ok` is
|
|
494
747
|
* true only when neither holds. */
|
|
495
|
-
export function runVerifyPolicy(raw: unknown):
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
748
|
+
export async function runVerifyPolicy(raw: unknown): Promise<
|
|
749
|
+
ToolResponse<{
|
|
750
|
+
ok: boolean
|
|
751
|
+
permit: { permitted: boolean; reason: string | null }
|
|
752
|
+
denies: Array<{ dimension: string; denied: boolean; reason: string | null }>
|
|
753
|
+
dimensionsCovered: number
|
|
754
|
+
}>
|
|
755
|
+
> {
|
|
501
756
|
const parsed = VerifyPolicyInputSchema.safeParse(raw)
|
|
502
757
|
if (!parsed.success) {
|
|
503
758
|
return { ok: false, error: validationError('verify_policy', parsed.error.issues) }
|
|
504
759
|
}
|
|
505
760
|
const input: VerifyPolicyInput = parsed.data
|
|
506
|
-
const
|
|
761
|
+
const resolved = await resolveCheckInputs(input, 'verify_policy')
|
|
762
|
+
if (!resolved.ok) return { ok: false, error: resolved.error }
|
|
763
|
+
const ctx = evalContextFromRecording(resolved.data.permitTx)
|
|
507
764
|
if (!ctx) return { ok: false, error: noInvocationError('verify_policy') }
|
|
508
765
|
try {
|
|
509
|
-
const predicate =
|
|
766
|
+
const predicate = resolved.data.predicate
|
|
510
767
|
const cases = generateCases(predicate, ctx)
|
|
511
768
|
const permitRes = evaluate(predicate, cases.permit)
|
|
512
769
|
const denies = cases.denies.map((d) => {
|
|
@@ -645,7 +902,7 @@ function buildRpcClientFromInput(
|
|
|
645
902
|
* policies are pinned. The caller resolves the expected pin per network;
|
|
646
903
|
* this function stays pure so it is easy to test. */
|
|
647
904
|
function enforceInterpreterPin(
|
|
648
|
-
policies: InstallPolicyInput['rule']['policies'],
|
|
905
|
+
policies: NonNullable<InstallPolicyInput['rule']>['policies'],
|
|
649
906
|
allowUnpinned: boolean | undefined,
|
|
650
907
|
expectedInterpreterAddress: string
|
|
651
908
|
): ToolError | null {
|