@crediolabs/policy-synth 0.1.18 → 0.2.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/README.md +3 -2
- package/dist/install/authority-overlap.d.ts +134 -0
- package/dist/install/authority-overlap.js +0 -0
- package/dist/install/build-add-context-rule.d.ts +8 -0
- package/dist/install/build-add-context-rule.js +1 -1
- package/dist/install/build-merge-policy.d.ts +70 -0
- package/dist/install/build-merge-policy.js +130 -0
- package/dist/install/index.d.ts +2 -0
- package/dist/install/index.js +7 -0
- package/dist/install/plan-merge-policy.d.ts +49 -0
- package/dist/install/plan-merge-policy.js +86 -0
- package/dist/install/read-account-rules.d.ts +100 -0
- package/dist/install/read-account-rules.js +283 -0
- package/dist/run/index.d.ts +93 -8
- package/dist/run/index.js +282 -11
- package/dist/run/schemas.d.ts +290 -11
- package/dist/run/schemas.js +77 -11
- package/dist-cjs/install/authority-overlap.d.ts +134 -0
- package/dist-cjs/install/authority-overlap.js +0 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
- package/dist-cjs/install/build-add-context-rule.js +1 -0
- package/dist-cjs/install/build-merge-policy.d.ts +70 -0
- package/dist-cjs/install/build-merge-policy.js +134 -0
- package/dist-cjs/install/index.d.ts +2 -0
- package/dist-cjs/install/index.js +23 -2
- package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
- package/dist-cjs/install/plan-merge-policy.js +90 -0
- package/dist-cjs/install/read-account-rules.d.ts +100 -0
- package/dist-cjs/install/read-account-rules.js +296 -0
- package/dist-cjs/run/index.d.ts +93 -8
- package/dist-cjs/run/index.js +283 -10
- package/dist-cjs/run/schemas.d.ts +290 -11
- package/dist-cjs/run/schemas.js +78 -12
- package/package.json +1 -1
- package/src/install/authority-overlap.ts +0 -0
- package/src/install/build-add-context-rule.ts +12 -1
- package/src/install/build-merge-policy.ts +219 -0
- package/src/install/index.ts +34 -0
- package/src/install/plan-merge-policy.ts +133 -0
- package/src/install/read-account-rules.ts +376 -0
- package/src/run/index.ts +386 -14
- package/src/run/schemas.ts +84 -11
package/src/run/index.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// drive the CLI (which calls into the same core directly without MCP).
|
|
19
19
|
|
|
20
20
|
import { createHash } from 'node:crypto'
|
|
21
|
-
import { rpc } from '@stellar/stellar-sdk'
|
|
21
|
+
import { rpc, xdr } from '@stellar/stellar-sdk'
|
|
22
22
|
import {
|
|
23
23
|
type ErrorCode,
|
|
24
24
|
type MandateSpec,
|
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
type ToolError,
|
|
36
36
|
type ToolResponse,
|
|
37
37
|
} from '../index.ts'
|
|
38
|
+
import {
|
|
39
|
+
type AuthorityOverlap,
|
|
40
|
+
type ContextType,
|
|
41
|
+
findAuthorityOverlaps,
|
|
42
|
+
} from '../install/authority-overlap.ts'
|
|
38
43
|
import {
|
|
39
44
|
type BuildInstallPolicyResult,
|
|
40
45
|
type BuildRevokePolicyResult,
|
|
@@ -43,7 +48,16 @@ import {
|
|
|
43
48
|
type InstallRpcClient,
|
|
44
49
|
rpcClientFromServer,
|
|
45
50
|
} from '../install/build-install-policy.ts'
|
|
51
|
+
import { buildMergePolicyXdr } from '../install/build-merge-policy.ts'
|
|
46
52
|
import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
|
|
53
|
+
import { planMergePolicy } from '../install/plan-merge-policy.ts'
|
|
54
|
+
import {
|
|
55
|
+
accountRuleReaderFromServer,
|
|
56
|
+
collectObservedRules,
|
|
57
|
+
nonceLedgerKey,
|
|
58
|
+
} from '../install/read-account-rules.ts'
|
|
59
|
+
import { decodePredicate } from '../predicate/decode.ts'
|
|
60
|
+
import { encodePredicate } from '../predicate/encode.ts'
|
|
47
61
|
import type { SimulationResult } from '../verify/envelope.ts'
|
|
48
62
|
import { simulatePolicy, verifyPolicy } from '../verify/index.ts'
|
|
49
63
|
import {
|
|
@@ -51,10 +65,12 @@ import {
|
|
|
51
65
|
GetInterpreterInfoInputSchema,
|
|
52
66
|
type InstallPolicyInput,
|
|
53
67
|
InstallPolicyInputSchema,
|
|
68
|
+
type MergePolicyInput,
|
|
69
|
+
MergePolicyInputSchema,
|
|
54
70
|
NETWORK_PASSPHRASES,
|
|
55
71
|
PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
|
|
56
72
|
PINNED_INTERPRETER_GRAMMAR_VERSION,
|
|
57
|
-
|
|
73
|
+
PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK,
|
|
58
74
|
type RecordTransactionInput,
|
|
59
75
|
RecordTransactionInputSchema,
|
|
60
76
|
type RevokePolicyInput,
|
|
@@ -95,7 +111,7 @@ export {
|
|
|
95
111
|
PINNED_INTERPRETER_GRAMMAR_VERSION,
|
|
96
112
|
PINNED_INTERPRETER_MAINNET_ADDRESS,
|
|
97
113
|
PINNED_INTERPRETER_TESTNET_ADDRESS,
|
|
98
|
-
|
|
114
|
+
PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK,
|
|
99
115
|
PredicateLeafSchema,
|
|
100
116
|
PredicateNodeSchema,
|
|
101
117
|
RecordedTransactionSchema,
|
|
@@ -335,9 +351,7 @@ export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>>
|
|
|
335
351
|
* comes from the RPC). Both gates accept an explicit opt-in flag.
|
|
336
352
|
* Pin selection follows `input.network` (defaults to `testnet` so the
|
|
337
353
|
* pre-mainnet callers keep working unchanged). */
|
|
338
|
-
export async function runInstallPolicy(
|
|
339
|
-
raw: unknown
|
|
340
|
-
): Promise<ToolResponse<BuildInstallPolicyResult>> {
|
|
354
|
+
export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<InstallPolicyResult>> {
|
|
341
355
|
const parsed = InstallPolicyInputSchema.safeParse(raw)
|
|
342
356
|
if (!parsed.success) {
|
|
343
357
|
return {
|
|
@@ -383,6 +397,27 @@ export async function runInstallPolicy(
|
|
|
383
397
|
const predicateHash = createHash('sha256')
|
|
384
398
|
.update(Buffer.from(encodedPredicate, 'base64'))
|
|
385
399
|
.digest('hex')
|
|
400
|
+
|
|
401
|
+
// ---- Cross-rule authority scan ----
|
|
402
|
+
// OZ enforces only the policies of the rule the caller names, so a signer
|
|
403
|
+
// who also sits in a wider rule keeps that wider authority no matter what
|
|
404
|
+
// this predicate says. Refuse by default when the wider rule has no policy
|
|
405
|
+
// at all, because that makes this install decorative.
|
|
406
|
+
const authorityScan: AuthorityScanReport | undefined = input.skipAuthorityScan
|
|
407
|
+
? { ran: false, skipped: true, reason: 'skipped at caller request', overlaps: [] }
|
|
408
|
+
: await scanAuthorityOverlap({
|
|
409
|
+
smartAccount: input.smartAccount,
|
|
410
|
+
interpreterAddress: expectedInterpreter,
|
|
411
|
+
rule: input.rule,
|
|
412
|
+
encodedPredicate,
|
|
413
|
+
rpcUrl: input.rpcUrl ?? expectedRpc,
|
|
414
|
+
network,
|
|
415
|
+
})
|
|
416
|
+
const overlapError = enforceAuthorityScan(authorityScan, input.allowAuthorityOverlap)
|
|
417
|
+
if (overlapError) {
|
|
418
|
+
return { ok: false, error: overlapError }
|
|
419
|
+
}
|
|
420
|
+
|
|
386
421
|
const result = await buildInstallPolicyXdr({
|
|
387
422
|
smartAccount: input.smartAccount,
|
|
388
423
|
sourceAccount: input.sourceAccount,
|
|
@@ -394,7 +429,7 @@ export async function runInstallPolicy(
|
|
|
394
429
|
rpc: rpcClient,
|
|
395
430
|
...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
|
|
396
431
|
})
|
|
397
|
-
return { ok: true, data: result }
|
|
432
|
+
return { ok: true, data: authorityScan ? { ...result, authorityScan } : result }
|
|
398
433
|
} catch (e) {
|
|
399
434
|
return {
|
|
400
435
|
ok: false,
|
|
@@ -403,6 +438,343 @@ export async function runInstallPolicy(
|
|
|
403
438
|
}
|
|
404
439
|
}
|
|
405
440
|
|
|
441
|
+
/** Default-deny on the cross-rule scan.
|
|
442
|
+
*
|
|
443
|
+
* Refuses whenever the scan cannot establish that this policy binds the calls
|
|
444
|
+
* it names. An unpoliced neighbour provably does not constrain them. An
|
|
445
|
+
* opaque one, policed by a contract this tool cannot decode, is not KNOWN to,
|
|
446
|
+
* and "not known to" is not "safe" - the same posture as the interpreter and
|
|
447
|
+
* RPC pins. An incomplete scan is refused for the same reason: the overlap
|
|
448
|
+
* list is then a subset of the account, so an empty list proves nothing.
|
|
449
|
+
*
|
|
450
|
+
* `not-restricting` is reported but does NOT block. Both rules are ours and
|
|
451
|
+
* both constrain the calls, and the conjunction remedy is offered; refusing
|
|
452
|
+
* there would also block the legitimate act of adding a separate capability,
|
|
453
|
+
* which OZ composes correctly as a union.
|
|
454
|
+
*
|
|
455
|
+
* Returns a ToolError or null, matching `enforceInterpreterPin`. */
|
|
456
|
+
export function enforceAuthorityScan(
|
|
457
|
+
scan: AuthorityScanReport | undefined,
|
|
458
|
+
allowOverlap: boolean | undefined
|
|
459
|
+
): ToolError | null {
|
|
460
|
+
if (!scan || allowOverlap === true) return null
|
|
461
|
+
|
|
462
|
+
// Default-deny by exclusion rather than by enumeration: anything that is not
|
|
463
|
+
// the one known-safe severity blocks. Listing the blocking severities
|
|
464
|
+
// instead would mean a severity added later silently passes until someone
|
|
465
|
+
// remembers to add it here, and the safe direction is the opposite.
|
|
466
|
+
const blocking = scan.overlaps.filter((o) => o.severity !== 'not-restricting')
|
|
467
|
+
const unpoliced = blocking.filter((o) => o.severity === 'bypass').map((o) => o.ruleId)
|
|
468
|
+
const opaque = blocking.filter((o) => o.severity === 'unknown').map((o) => o.ruleId)
|
|
469
|
+
const unrecognised = blocking
|
|
470
|
+
.filter((o) => o.severity !== 'bypass' && o.severity !== 'unknown')
|
|
471
|
+
.map((o) => o.ruleId)
|
|
472
|
+
|
|
473
|
+
if (blocking.length > 0) {
|
|
474
|
+
const parts = [
|
|
475
|
+
unpoliced.length > 0 ? `rule ${unpoliced.join(', ')} has no policy attached` : '',
|
|
476
|
+
opaque.length > 0
|
|
477
|
+
? `rule ${opaque.join(', ')} is policed by a contract this tool cannot decode`
|
|
478
|
+
: '',
|
|
479
|
+
unrecognised.length > 0
|
|
480
|
+
? `rule ${unrecognised.join(', ')} carries an overlap this build does not recognise`
|
|
481
|
+
: '',
|
|
482
|
+
].filter(Boolean)
|
|
483
|
+
return {
|
|
484
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
485
|
+
message: `install_policy: a signer of this rule can already make the same calls through another context rule, so this policy is not established to restrict them: ${parts.join('; ')}. Remove the shared signer from that rule, attach a policy this tool can read, or set allowAuthorityOverlap: true to install anyway`,
|
|
486
|
+
severity: 'error',
|
|
487
|
+
retryable: false,
|
|
488
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// A scan that was meant to run and threw tells us nothing. Reporting it in
|
|
493
|
+
// a string field and proceeding is the same fail-open shape as an incomplete
|
|
494
|
+
// scan, and a caller that does not read `ran` cannot tell it from a clean
|
|
495
|
+
// result.
|
|
496
|
+
if (scan.ran === false && scan.skipped !== true) {
|
|
497
|
+
return {
|
|
498
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
499
|
+
message: `install_policy: the cross-rule authority scan could not run (${scan.reason ?? 'unknown error'}), so it cannot establish that this policy restricts anything; retry, or set allowAuthorityOverlap: true to install without that assurance`,
|
|
500
|
+
severity: 'error',
|
|
501
|
+
retryable: true,
|
|
502
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (scan.incomplete === true) {
|
|
507
|
+
return {
|
|
508
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
509
|
+
message:
|
|
510
|
+
'install_policy: the account has more context rules than the scan could account for, so the overlap result is incomplete and cannot establish that this policy restricts anything; set allowAuthorityOverlap: true to install without that assurance',
|
|
511
|
+
severity: 'error',
|
|
512
|
+
retryable: false,
|
|
513
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return null
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The install response, plus what the cross-rule scan found. The scan is
|
|
521
|
+
* advisory data about the account, not part of the transaction, so it is
|
|
522
|
+
* additive: a caller that ignores it gets exactly the previous shape. */
|
|
523
|
+
export type InstallPolicyResult = BuildInstallPolicyResult & {
|
|
524
|
+
authorityScan?: AuthorityScanReport
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** What the cross-rule scan found, carried on the install response so the
|
|
528
|
+
* review surface can show it alongside the transaction being signed.
|
|
529
|
+
*
|
|
530
|
+
* SCOPE, and it is narrow: this answers "can a signer OF THIS RULE reach the
|
|
531
|
+
* same calls through a different rule". A rule sharing no signer with this
|
|
532
|
+
* one cannot be reached by this rule's signers, so it is not a way around
|
|
533
|
+
* this policy; it is a different principal's authority, which no policy
|
|
534
|
+
* installed here was ever going to constrain. Other rules keep their own
|
|
535
|
+
* signers, and an account administrator can add signers or rules afterwards.
|
|
536
|
+
*
|
|
537
|
+
* An empty `overlaps` is therefore NOT a statement that the account is safe,
|
|
538
|
+
* only that this rule's own signers gain no unconstrained path through the
|
|
539
|
+
* rules that exist right now. */
|
|
540
|
+
export interface AuthorityScanReport {
|
|
541
|
+
/** False when the scan did not run. `reason` then says why, and the absence
|
|
542
|
+
* of overlaps proves nothing. */
|
|
543
|
+
ran: boolean
|
|
544
|
+
/** True when the caller passed `skipAuthorityScan`. Distinguishes a
|
|
545
|
+
* deliberate skip from a scan that tried and failed: both carry
|
|
546
|
+
* `ran: false`, but only the failure refuses the install. Recorded rather
|
|
547
|
+
* than omitted so the response shows that no opinion was formed, instead
|
|
548
|
+
* of looking like a version that never had the check. */
|
|
549
|
+
skipped?: boolean
|
|
550
|
+
/** True when the account has more rules than the scan accounted for, so the
|
|
551
|
+
* overlap list is a subset. */
|
|
552
|
+
incomplete?: boolean
|
|
553
|
+
reason?: string
|
|
554
|
+
overlaps: AuthorityOverlap[]
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Read the account's other context rules and report where this install's
|
|
558
|
+
* signers already hold authority over the same calls.
|
|
559
|
+
*
|
|
560
|
+
* A failure to read is reported rather than thrown, and the caller-facing
|
|
561
|
+
* decision is made by `enforceAuthorityScan`: a scan that tried and failed
|
|
562
|
+
* tells us nothing, so it refuses rather than passing as a clean account.
|
|
563
|
+
*
|
|
564
|
+
* The account data this trusts comes from whichever RPC answered, so a
|
|
565
|
+
* hostile RPC could describe an account with no overlapping rules. That is
|
|
566
|
+
* bounded by the pin already enforced above: `enforceRpcPin` returns before
|
|
567
|
+
* this runs, so the URL is the pinned one for the network unless the caller
|
|
568
|
+
* explicitly set `allowUnpinnedRpcUrl`. This scan deliberately does not add a
|
|
569
|
+
* second pin check, because two places deciding the same thing drift. */
|
|
570
|
+
async function scanAuthorityOverlap(args: {
|
|
571
|
+
smartAccount: string
|
|
572
|
+
interpreterAddress: string
|
|
573
|
+
rule: InstallPolicyInput['rule']
|
|
574
|
+
encodedPredicate: string
|
|
575
|
+
rpcUrl: string
|
|
576
|
+
network: Network
|
|
577
|
+
}): Promise<AuthorityScanReport | undefined> {
|
|
578
|
+
try {
|
|
579
|
+
if (!args.encodedPredicate) {
|
|
580
|
+
// Not applicable rather than failed: this rule installs no interpreter
|
|
581
|
+
// predicate, so there is nothing of ours for another rule to undercut.
|
|
582
|
+
// Returning undefined keeps it out of the refusal path, which is
|
|
583
|
+
// reserved for scans that were meant to run and could not.
|
|
584
|
+
return undefined
|
|
585
|
+
}
|
|
586
|
+
const server = new rpc.Server(args.rpcUrl, { allowHttp: false })
|
|
587
|
+
const reader = accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[args.network])
|
|
588
|
+
const collected = await collectObservedRules({
|
|
589
|
+
reader,
|
|
590
|
+
smartAccount: args.smartAccount,
|
|
591
|
+
interpreterAddress: args.interpreterAddress,
|
|
592
|
+
})
|
|
593
|
+
const overlaps = findAuthorityOverlaps({
|
|
594
|
+
intended: {
|
|
595
|
+
// `add_context_rule` always creates a NEW rule, so there is no id to
|
|
596
|
+
// exclude; -1 matches nothing on the account.
|
|
597
|
+
ruleId: -1,
|
|
598
|
+
contextType: toOverlapContextType(args.rule.contextRuleType),
|
|
599
|
+
signers: args.rule.signers,
|
|
600
|
+
predicate: decodePredicate(args.encodedPredicate),
|
|
601
|
+
},
|
|
602
|
+
existing: collected.rules,
|
|
603
|
+
})
|
|
604
|
+
return { ran: true, incomplete: collected.incomplete, overlaps }
|
|
605
|
+
} catch (e) {
|
|
606
|
+
return {
|
|
607
|
+
ran: false,
|
|
608
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
609
|
+
overlaps: [],
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The rule draft names the callee `contract`; the analyser calls it
|
|
615
|
+
* `address`. Same value, two vocabularies. */
|
|
616
|
+
function toOverlapContextType(ct: InstallPolicyInput['rule']['contextRuleType']): ContextType {
|
|
617
|
+
switch (ct.kind) {
|
|
618
|
+
case 'call_contract':
|
|
619
|
+
return { kind: 'call_contract', address: ct.contract }
|
|
620
|
+
case 'create_contract':
|
|
621
|
+
return { kind: 'create_contract', wasmHash: ct.wasmHash }
|
|
622
|
+
default:
|
|
623
|
+
return { kind: 'default' }
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The merge response: one step's transaction plus what it will cost. */
|
|
628
|
+
export interface MergePolicyResult {
|
|
629
|
+
unsignedXdr: string
|
|
630
|
+
smartAccount: string
|
|
631
|
+
sourceAccount: string
|
|
632
|
+
step: 'detach' | 'reinstall'
|
|
633
|
+
call: { contract: string; fn: string; ruleId: number }
|
|
634
|
+
authNonce: string
|
|
635
|
+
authValidUntilLedger: number
|
|
636
|
+
rootInvocationXdr: string
|
|
637
|
+
/** sha256 of the merged predicate, so the caller can pin what step 2 will
|
|
638
|
+
* install while they are still looking at step 1. */
|
|
639
|
+
mergedPredicateHash: string
|
|
640
|
+
mergedPredicateBlobBase64: string
|
|
641
|
+
warnings: string[]
|
|
642
|
+
followUp: string
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** `merge_policy` body - the tightening remedy for a cross-rule overlap.
|
|
646
|
+
*
|
|
647
|
+
* Replaces a rule's predicate with the conjunction of it and a new one. This
|
|
648
|
+
* is the action `install_policy` recommends when it reports an overlap
|
|
649
|
+
* between two rules our interpreter polices, and it is deliberately NOT
|
|
650
|
+
* something `install_policy` does on its own: it detaches a live policy, so
|
|
651
|
+
* the operator has to ask for it.
|
|
652
|
+
*
|
|
653
|
+
* Two transactions in order. `add_policy` refuses a policy already on the
|
|
654
|
+
* rule, so the old attachment goes first, and the second transaction cannot
|
|
655
|
+
* be simulated until the first confirms. */
|
|
656
|
+
export async function runMergePolicy(raw: unknown): Promise<ToolResponse<MergePolicyResult>> {
|
|
657
|
+
const parsed = MergePolicyInputSchema.safeParse(raw)
|
|
658
|
+
if (!parsed.success) {
|
|
659
|
+
return { ok: false, error: validationError('install_policy', parsed.error.issues) }
|
|
660
|
+
}
|
|
661
|
+
const input: MergePolicyInput = parsed.data
|
|
662
|
+
const network: Network = input.network ?? 'testnet'
|
|
663
|
+
const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
|
|
664
|
+
const expectedRpc = RPC_URL_BY_NETWORK[network]
|
|
665
|
+
|
|
666
|
+
const rpcPinningError = enforceRpcPin(
|
|
667
|
+
'install_policy',
|
|
668
|
+
input.rpcUrl,
|
|
669
|
+
input.allowUnpinnedRpcUrl,
|
|
670
|
+
expectedRpc,
|
|
671
|
+
network
|
|
672
|
+
)
|
|
673
|
+
if (rpcPinningError) return { ok: false, error: rpcPinningError }
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const rpcUrl = input.rpcUrl ?? expectedRpc
|
|
677
|
+
const server = new rpc.Server(rpcUrl, { allowHttp: false })
|
|
678
|
+
const reader = accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network])
|
|
679
|
+
const collected = await collectObservedRules({
|
|
680
|
+
reader,
|
|
681
|
+
smartAccount: input.smartAccount,
|
|
682
|
+
interpreterAddress: expectedInterpreter,
|
|
683
|
+
})
|
|
684
|
+
const rule = collected.rules.find((r) => r.id === input.ruleId)
|
|
685
|
+
if (!rule) {
|
|
686
|
+
return {
|
|
687
|
+
ok: false,
|
|
688
|
+
error: {
|
|
689
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
690
|
+
message: `merge_policy: rule ${input.ruleId} was not found on ${input.smartAccount}${
|
|
691
|
+
collected.incomplete
|
|
692
|
+
? ' (the rule scan was incomplete, so it may exist but was not reached)'
|
|
693
|
+
: ''
|
|
694
|
+
}`,
|
|
695
|
+
severity: 'error',
|
|
696
|
+
retryable: false,
|
|
697
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
698
|
+
},
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const plan = planMergePolicy({
|
|
703
|
+
rule,
|
|
704
|
+
interpreterAddress: expectedInterpreter,
|
|
705
|
+
incoming: decodePredicate(input.incomingPredicateBlobBase64),
|
|
706
|
+
step: input.step,
|
|
707
|
+
})
|
|
708
|
+
if (!plan.ok) {
|
|
709
|
+
return {
|
|
710
|
+
ok: false,
|
|
711
|
+
error: {
|
|
712
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
713
|
+
message: `merge_policy: ${plan.reason}`,
|
|
714
|
+
severity: 'error',
|
|
715
|
+
retryable: false,
|
|
716
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const encoded = encodePredicate(plan.predicate)
|
|
722
|
+
|
|
723
|
+
// The nonce is read, not assumed. OZ's `remove_policy` discards the result
|
|
724
|
+
// of `try_uninstall`, so a detach whose uninstall panicked - our
|
|
725
|
+
// `uninstall` panics MissingState when the master set has been archived -
|
|
726
|
+
// detaches the policy while leaving our nonce behind. Re-installing at 1
|
|
727
|
+
// would then be refused as a replay and the rule would sit unpoliced.
|
|
728
|
+
let installNonce = 1
|
|
729
|
+
const nonceWarnings: string[] = []
|
|
730
|
+
if (input.step === 'reinstall') {
|
|
731
|
+
const entries = await server.getLedgerEntries(
|
|
732
|
+
nonceLedgerKey(expectedInterpreter, input.smartAccount, input.ruleId)
|
|
733
|
+
)
|
|
734
|
+
const raw = entries.entries?.[0]?.val
|
|
735
|
+
const stored =
|
|
736
|
+
raw && raw.switch() === xdr.LedgerEntryType.contractData()
|
|
737
|
+
? raw.contractData().val()
|
|
738
|
+
: undefined
|
|
739
|
+
if (stored && stored.switch() === xdr.ScValType.scvU32()) {
|
|
740
|
+
installNonce = stored.u32() + 1
|
|
741
|
+
nonceWarnings.push(
|
|
742
|
+
`the previous uninstall did not complete: rule ${input.ruleId} still holds interpreter state at nonce ${stored.u32()}, so this reinstalls at ${installNonce} rather than 1. The rule's counters were NOT reset.`
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const built = await buildMergePolicyXdr({
|
|
748
|
+
smartAccount: input.smartAccount,
|
|
749
|
+
sourceAccount: input.sourceAccount,
|
|
750
|
+
networkPassphrase: NETWORK_PASSPHRASES[network],
|
|
751
|
+
ruleId: input.ruleId,
|
|
752
|
+
policyId: plan.policyId,
|
|
753
|
+
interpreterAddress: expectedInterpreter,
|
|
754
|
+
step: input.step,
|
|
755
|
+
encodedPredicate: encoded.encodedPredicate,
|
|
756
|
+
predicateHash: encoded.predicateHash,
|
|
757
|
+
installNonce,
|
|
758
|
+
...(plan.oracleParams ? { oracleParams: plan.oracleParams } : {}),
|
|
759
|
+
rpc: rpcClientFromServer(server, NETWORK_PASSPHRASES[network]),
|
|
760
|
+
...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
ok: true,
|
|
765
|
+
data: {
|
|
766
|
+
...built,
|
|
767
|
+
mergedPredicateHash: encoded.predicateHash,
|
|
768
|
+
mergedPredicateBlobBase64: encoded.encodedPredicate,
|
|
769
|
+
warnings: [...plan.warnings, ...nonceWarnings],
|
|
770
|
+
followUp: plan.followUp,
|
|
771
|
+
},
|
|
772
|
+
}
|
|
773
|
+
} catch (e) {
|
|
774
|
+
return { ok: false, error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e) }
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
406
778
|
/** `revoke_policy` body - thin wrapper over `buildRevokePolicyXdr`.
|
|
407
779
|
* Emits an unsigned XDR for `account.remove_context_rule(ruleId)`; the
|
|
408
780
|
* smart account itself handles uninstalling each attached policy. Auth
|
|
@@ -469,12 +841,12 @@ export async function runRevokePolicy(
|
|
|
469
841
|
* fabricating it would be a lie on a security surface; the live
|
|
470
842
|
* mismatch check is worth MORE).
|
|
471
843
|
*
|
|
472
|
-
* Network-aware: `input.network` selects
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
844
|
+
* Network-aware: `input.network` selects the interpreter address, the RPC
|
|
845
|
+
* and the wasm hash. The networks run different binaries - testnet carries
|
|
846
|
+
* the selector-leaf minimum and the signer-set cap, mainnet predates both -
|
|
847
|
+
* so the hash is read through
|
|
848
|
+
* `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK`. UNAUDITED at the time of
|
|
849
|
+
* writing.
|
|
478
850
|
*
|
|
479
851
|
* Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
|
|
480
852
|
* call, the auth-digest + the answer bind to whichever RPC answered, so
|
|
@@ -522,7 +894,7 @@ export async function runGetInterpreterInfo(
|
|
|
522
894
|
const info = getInterpreterInfo({
|
|
523
895
|
pinnedAddress,
|
|
524
896
|
pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
|
|
525
|
-
pinnedWasmHash:
|
|
897
|
+
pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK[network],
|
|
526
898
|
network,
|
|
527
899
|
...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
|
|
528
900
|
})
|
package/src/run/schemas.ts
CHANGED
|
@@ -176,6 +176,23 @@ export const ComposeUserResponsesSchema = z
|
|
|
176
176
|
swapRecipientAllowlist: z
|
|
177
177
|
.array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
|
|
178
178
|
.optional(),
|
|
179
|
+
// Per-asset oracle-price bound, one entry per asset. Reaches
|
|
180
|
+
// `compose-from-recording` and emits an `oracle_price` compare. Declared
|
|
181
|
+
// here because the object is `.passthrough()`: the field already worked
|
|
182
|
+
// undeclared, which left the accepted surface wider than the documented
|
|
183
|
+
// one. `decimals` is REQUIRED - oracle prices normalise to 9 dp and a
|
|
184
|
+
// threshold silently assumed to share that basis is what let a raw 14-dp
|
|
185
|
+
// bound permit everything.
|
|
186
|
+
oraclePriceBound: z
|
|
187
|
+
.array(
|
|
188
|
+
z.object({
|
|
189
|
+
asset: z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'),
|
|
190
|
+
operator: z.enum(['eq', 'lt', 'lte', 'gt', 'gte']),
|
|
191
|
+
value: z.string().regex(/^[0-9]+$/),
|
|
192
|
+
decimals: z.number().int().min(0).max(U32_MAX),
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
.optional(),
|
|
179
196
|
})
|
|
180
197
|
.passthrough()
|
|
181
198
|
|
|
@@ -502,20 +519,31 @@ const MandateSpecSchemaForRule = z
|
|
|
502
519
|
/** Pinned interpreter address (testnet).
|
|
503
520
|
* Single source for the MCP layer; do not embed elsewhere. */
|
|
504
521
|
export const PINNED_INTERPRETER_TESTNET_ADDRESS =
|
|
505
|
-
'
|
|
522
|
+
'CALHNU4LXZRKFAXYRBODTGDANTNXHJPBTJNYGLUWLIFIP24NWSGWIE4K'
|
|
506
523
|
|
|
507
|
-
/** Pinned interpreter address (mainnet).
|
|
508
|
-
*
|
|
509
|
-
* that was exercised on testnet (same wasm sha256, see
|
|
510
|
-
* PINNED_INTERPRETER_WASM_SHA256). The address differs because instance
|
|
511
|
-
* ids are network-scoped. UNAUDITED at the time of writing. */
|
|
524
|
+
/** Pinned interpreter address (mainnet), deployed 2026-08-04.
|
|
525
|
+
* UNAUDITED at the time of writing. */
|
|
512
526
|
export const PINNED_INTERPRETER_MAINNET_ADDRESS =
|
|
513
527
|
'CALZAMUPREIRY4TULBEXIK77AUTOEJG63XLCPUWEHHQDOVK6ZVVS7VQ2'
|
|
514
528
|
|
|
515
|
-
/** Pinned interpreter wasm sha256 (hex).
|
|
529
|
+
/** Pinned interpreter wasm sha256 (hex), mainnet.
|
|
530
|
+
*
|
|
531
|
+
* The two networks no longer run the same binary. Testnet carries the
|
|
532
|
+
* selector-leaf minimum and the signer-set cap; mainnet predates both. Read
|
|
533
|
+
* the hash through `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK` rather than
|
|
534
|
+
* this constant unless mainnet is specifically what is meant, or
|
|
535
|
+
* `get_interpreter_info` will report a hash the queried network does not
|
|
536
|
+
* run. */
|
|
516
537
|
export const PINNED_INTERPRETER_WASM_SHA256 =
|
|
517
538
|
'6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443'
|
|
518
539
|
|
|
540
|
+
/** Pinned interpreter wasm sha256 (hex), testnet. Byte-identical to the
|
|
541
|
+
* artifact built from `contracts/policy-interpreter` at the commit that
|
|
542
|
+
* introduced errors 216 and 217, verified by fetching the deployed wasm back
|
|
543
|
+
* off chain. */
|
|
544
|
+
export const PINNED_INTERPRETER_WASM_SHA256_TESTNET =
|
|
545
|
+
'a4d58bc88fd82bbb8e223941ba5889db919717fae92ced13f6f55bfe583b8e22'
|
|
546
|
+
|
|
519
547
|
/** The grammar version the interpreter enforces (matches SELF_VERSION in
|
|
520
548
|
* contracts/policy-interpreter/src/version.rs). */
|
|
521
549
|
export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1
|
|
@@ -532,14 +560,18 @@ export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org'
|
|
|
532
560
|
* the deploy script hit during the 2026-08-04 mainnet rollout. */
|
|
533
561
|
export const MAINNET_RPC_URL = 'https://mainnet.sorobanrpc.com'
|
|
534
562
|
|
|
535
|
-
/** Pin + RPC lookup for the gate enforcement.
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
563
|
+
/** Pin + RPC lookup for the gate enforcement. Addresses, RPCs and wasm
|
|
564
|
+
* hashes are all network-scoped: the networks diverged when the
|
|
565
|
+
* selector-leaf and signer-cap controls were deployed to testnet ahead of
|
|
566
|
+
* mainnet. */
|
|
539
567
|
export const PINNED_INTERPRETER_ADDRESS_BY_NETWORK: Record<Network, string> = {
|
|
540
568
|
testnet: PINNED_INTERPRETER_TESTNET_ADDRESS,
|
|
541
569
|
mainnet: PINNED_INTERPRETER_MAINNET_ADDRESS,
|
|
542
570
|
}
|
|
571
|
+
export const PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK: Record<Network, string> = {
|
|
572
|
+
testnet: PINNED_INTERPRETER_WASM_SHA256_TESTNET,
|
|
573
|
+
mainnet: PINNED_INTERPRETER_WASM_SHA256,
|
|
574
|
+
}
|
|
543
575
|
export const RPC_URL_BY_NETWORK: Record<Network, string> = {
|
|
544
576
|
testnet: TESTNET_RPC_URL,
|
|
545
577
|
mainnet: MAINNET_RPC_URL,
|
|
@@ -607,6 +639,17 @@ export const InstallPolicyInputSchema = z
|
|
|
607
639
|
* everything. Selecting `network: 'mainnet'` is NOT an opt-in -
|
|
608
640
|
* the mainnet pin is its own deny-by-default anchor. */
|
|
609
641
|
allowUnpinnedInterpreter: z.boolean().optional(),
|
|
642
|
+
/** Opt-in to installing when a signer of this rule can already reach the
|
|
643
|
+
* same calls through a context rule that has NO policy attached. OZ lets
|
|
644
|
+
* the signer choose which rule authorises a call and enforces only that
|
|
645
|
+
* rule's policies, so an unpoliced rule covering the same calls makes
|
|
646
|
+
* this policy decorative. Default-deny, because the caller almost
|
|
647
|
+
* certainly believes they are restricting something. */
|
|
648
|
+
allowAuthorityOverlap: z.boolean().optional(),
|
|
649
|
+
/** Skip the cross-rule authority scan entirely. The scan costs one RPC
|
|
650
|
+
* read per rule on the account; skipping it means the response carries
|
|
651
|
+
* no statement about what the signers can already do. */
|
|
652
|
+
skipAuthorityScan: z.boolean().optional(),
|
|
610
653
|
/** Base fee in stroops; defaults to BASE_FEE (100). */
|
|
611
654
|
baseFee: z.number().int().positive().optional(),
|
|
612
655
|
})
|
|
@@ -615,6 +658,36 @@ export const InstallPolicyInputSchema = z
|
|
|
615
658
|
})
|
|
616
659
|
export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
|
|
617
660
|
|
|
661
|
+
/** `merge_policy` input.
|
|
662
|
+
*
|
|
663
|
+
* The tightening remedy for a cross-rule overlap: replace a rule's predicate
|
|
664
|
+
* with the conjunction of it and a new one. Two transactions, in order,
|
|
665
|
+
* because OZ refuses to re-attach a policy already on the rule - so the
|
|
666
|
+
* caller runs `detach`, waits for it to confirm, then runs `reinstall`. */
|
|
667
|
+
export const MergePolicyInputSchema = z
|
|
668
|
+
.object({
|
|
669
|
+
smartAccount: z
|
|
670
|
+
.string()
|
|
671
|
+
.regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
|
|
672
|
+
sourceAccount: z
|
|
673
|
+
.string()
|
|
674
|
+
.regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
|
|
675
|
+
/** The rule whose predicate is being tightened. */
|
|
676
|
+
ruleId: z.number().int().nonnegative(),
|
|
677
|
+
/** The predicate to conjoin, base64 canonical ScVal, as emitted by
|
|
678
|
+
* `synthesize_policy`. */
|
|
679
|
+
incomingPredicateBlobBase64: z.string().min(1),
|
|
680
|
+
/** Which half of the remedy to build. */
|
|
681
|
+
step: z.enum(['detach', 'reinstall']),
|
|
682
|
+
network: NetworkSchema.optional(),
|
|
683
|
+
rpcUrl: z.string().url().optional(),
|
|
684
|
+
allowUnpinnedRpcUrl: z.boolean().optional(),
|
|
685
|
+
baseFee: z.number().int().positive().optional(),
|
|
686
|
+
})
|
|
687
|
+
.strict()
|
|
688
|
+
|
|
689
|
+
export type MergePolicyInput = z.infer<typeof MergePolicyInputSchema>
|
|
690
|
+
|
|
618
691
|
export const RevokePolicyInputSchema = z
|
|
619
692
|
.object({
|
|
620
693
|
/** The smart account contract address (C...). */
|