@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
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 {
|