@crediolabs/policy-synth 1.1.1 → 1.3.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/authority-overlap.d.ts +41 -1
- package/dist/install/authority-overlap.js +58 -9
- package/dist/install/read-account-rules.d.ts +23 -1
- package/dist/install/read-account-rules.js +56 -1
- package/dist/run/index.d.ts +17 -0
- package/dist/run/index.js +94 -1
- package/dist/run/schemas.d.ts +101 -0
- package/dist/run/schemas.js +37 -0
- package/dist-cjs/install/authority-overlap.d.ts +41 -1
- package/dist-cjs/install/authority-overlap.js +58 -9
- package/dist-cjs/install/read-account-rules.d.ts +23 -1
- package/dist-cjs/install/read-account-rules.js +58 -0
- package/dist-cjs/run/index.d.ts +17 -0
- package/dist-cjs/run/index.js +95 -1
- package/dist-cjs/run/schemas.d.ts +101 -0
- package/dist-cjs/run/schemas.js +37 -0
- package/package.json +1 -1
- package/src/install/authority-overlap.ts +112 -11
- package/src/install/read-account-rules.ts +74 -1
- package/src/run/index.ts +98 -1
- package/src/run/schemas.ts +37 -0
|
@@ -27,12 +27,13 @@ import {
|
|
|
27
27
|
Contract,
|
|
28
28
|
Keypair,
|
|
29
29
|
rpc,
|
|
30
|
+
scValToNative,
|
|
30
31
|
TransactionBuilder,
|
|
31
32
|
xdr,
|
|
32
33
|
} from '@stellar/stellar-sdk'
|
|
33
34
|
import { decodePredicate } from '../predicate/decode.ts'
|
|
34
35
|
import type { SignerDraft } from '../types.ts'
|
|
35
|
-
import type { ContextType, ObservedRule } from './authority-overlap.ts'
|
|
36
|
+
import type { ContextType, ObservedRule, SpendCap } from './authority-overlap.ts'
|
|
36
37
|
|
|
37
38
|
/** `storage.rs` - the third element of the persistent doc key tuple. */
|
|
38
39
|
export const K_DOC = 1
|
|
@@ -173,6 +174,49 @@ export function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined {
|
|
|
173
174
|
return field.bytes()
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/** Persistent-storage key for a rule's spend-cap data:
|
|
178
|
+
* `SpendingLimitStorageKey::AccountContext(account, rule_id)`, which the host
|
|
179
|
+
* encodes as an enum variant - the symbol first, then the payload. */
|
|
180
|
+
export function spendCapKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal {
|
|
181
|
+
return xdr.ScVal.scvVec([
|
|
182
|
+
xdr.ScVal.scvSymbol('AccountContext'),
|
|
183
|
+
new Address(smartAccount).toScVal(),
|
|
184
|
+
xdr.ScVal.scvU32(ruleId),
|
|
185
|
+
])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Ledger key for the OZ spend cap's own persistent entry. The policy exposes
|
|
189
|
+
* `get_spending_limit_data`, but that PANICS when nothing is installed, and a
|
|
190
|
+
* panic is indistinguishable from an RPC fault at the call site. Reading the
|
|
191
|
+
* entry lets "no cap here" come back as an absence instead. */
|
|
192
|
+
export function spendCapLedgerKey(
|
|
193
|
+
spendingLimit: string,
|
|
194
|
+
smartAccount: string,
|
|
195
|
+
ruleId: number
|
|
196
|
+
): xdr.LedgerKey {
|
|
197
|
+
return xdr.LedgerKey.contractData(
|
|
198
|
+
new xdr.LedgerKeyContractData({
|
|
199
|
+
contract: new Address(spendingLimit).toScAddress(),
|
|
200
|
+
key: spendCapKeyScVal(smartAccount, ruleId),
|
|
201
|
+
durability: xdr.ContractDataDurability.persistent(),
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `SpendingLimitData`, of which only the two installed parameters matter here.
|
|
207
|
+
* The running total and the history are deliberately ignored: they say what
|
|
208
|
+
* has been spent so far, which changes every call, while the scan is about
|
|
209
|
+
* what the rule PERMITS. */
|
|
210
|
+
export function decodeSpendCap(v: xdr.ScVal): SpendCap | undefined {
|
|
211
|
+
const periodLedgers = u32Of(mapField(v, 'period_ledgers'))
|
|
212
|
+
const limit = mapField(v, 'spending_limit')
|
|
213
|
+
if (periodLedgers === undefined || !limit) return undefined
|
|
214
|
+
if (limit.switch() !== xdr.ScValType.scvI128()) return undefined
|
|
215
|
+
const amount = scValToNative(limit)
|
|
216
|
+
if (typeof amount !== 'bigint') return undefined
|
|
217
|
+
return { amount: amount.toString(), periodLedgers }
|
|
218
|
+
}
|
|
219
|
+
|
|
176
220
|
// ---- collection -----
|
|
177
221
|
|
|
178
222
|
/** The three reads the scan needs. Kept as an interface so the collection
|
|
@@ -189,6 +233,14 @@ export interface AccountRuleReader {
|
|
|
189
233
|
smartAccount: string,
|
|
190
234
|
ruleId: number
|
|
191
235
|
): Promise<xdr.ScVal | undefined>
|
|
236
|
+
/** The OZ spend cap's persistent entry for this rule, read as a ledger entry.
|
|
237
|
+
* Optional so an existing reader keeps working: without it the scan simply
|
|
238
|
+
* reports no cap parameters, which is the same as it behaved before. */
|
|
239
|
+
getSpendCapData?(
|
|
240
|
+
spendingLimit: string,
|
|
241
|
+
smartAccount: string,
|
|
242
|
+
ruleId: number
|
|
243
|
+
): Promise<xdr.ScVal | undefined>
|
|
192
244
|
}
|
|
193
245
|
|
|
194
246
|
/** How far the id scan will probe before giving up. OZ imposes no per-account
|
|
@@ -227,6 +279,10 @@ export async function collectObservedRules(args: {
|
|
|
227
279
|
reader: AccountRuleReader
|
|
228
280
|
smartAccount: string
|
|
229
281
|
interpreterAddress: string
|
|
282
|
+
/** The pinned OZ spend cap. Supplying it fills in the PARAMETERS of a
|
|
283
|
+
* neighbour's cap; whether one is attached at all is decided from the
|
|
284
|
+
* rule's policy addresses and does not depend on this read succeeding. */
|
|
285
|
+
spendingLimitAddress?: string
|
|
230
286
|
maxRuleIdScan?: number
|
|
231
287
|
}): Promise<CollectedRules> {
|
|
232
288
|
const count = await args.reader.getContextRuleCount(args.smartAccount)
|
|
@@ -259,6 +315,16 @@ export async function collectObservedRules(args: {
|
|
|
259
315
|
unreadablePredicateRuleIds.push(rule.id)
|
|
260
316
|
}
|
|
261
317
|
}
|
|
318
|
+
|
|
319
|
+
const spendCapAddress = args.spendingLimitAddress
|
|
320
|
+
if (spendCapAddress !== undefined && rule.policyAddresses.includes(spendCapAddress)) {
|
|
321
|
+
const data = await args.reader.getSpendCapData?.(spendCapAddress, args.smartAccount, rule.id)
|
|
322
|
+
const cap = data ? decodeSpendCap(data) : undefined
|
|
323
|
+
// An unreadable cap is left absent rather than guessed at. The rule still
|
|
324
|
+
// counts as capped, because attachment came from its policy addresses,
|
|
325
|
+
// so failing this read weakens the REPORT and never the refusal.
|
|
326
|
+
if (cap) rule.spendCap = cap
|
|
327
|
+
}
|
|
262
328
|
rules.push(rule)
|
|
263
329
|
}
|
|
264
330
|
|
|
@@ -309,5 +375,12 @@ export function accountRuleReaderFromServer(
|
|
|
309
375
|
if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
|
|
310
376
|
return entry.contractData().val()
|
|
311
377
|
},
|
|
378
|
+
async getSpendCapData(spendingLimit, smartAccount, ruleId) {
|
|
379
|
+
const key = spendCapLedgerKey(spendingLimit, smartAccount, ruleId)
|
|
380
|
+
const res = await server.getLedgerEntries(key)
|
|
381
|
+
const entry = res.entries?.[0]?.val
|
|
382
|
+
if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
|
|
383
|
+
return entry.contractData().val()
|
|
384
|
+
},
|
|
312
385
|
}
|
|
313
386
|
}
|
package/src/run/index.ts
CHANGED
|
@@ -18,6 +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 { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
21
22
|
import { rpc } from '@stellar/stellar-sdk'
|
|
22
23
|
import { PLACEHOLDER_INTERPRETER_ADDRESS } from '../adapters/interpreter/adapter.ts'
|
|
23
24
|
import {
|
|
@@ -236,6 +237,38 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
/** The refusal message for an install the cross-rule scan proves cannot bind,
|
|
241
|
+
* or `undefined` when the install may proceed.
|
|
242
|
+
*
|
|
243
|
+
* Only `bypass` refuses, and it covers two proofs. Either the neighbouring
|
|
244
|
+
* rule carries NO policy, so a shared signer names it and the new predicate
|
|
245
|
+
* never runs; or the install carries a rolling total and a fully recognised
|
|
246
|
+
* neighbour serves the same calls without one, so the total is not a bound on
|
|
247
|
+
* the key. Both are proofs from data already in hand.
|
|
248
|
+
*
|
|
249
|
+
* `unknown` (a neighbour policed by a contract this tool cannot decode) stays
|
|
250
|
+
* advisory: it may well be tighter, and refusing on "cannot decode" would
|
|
251
|
+
* block installs on a guess. A `null` scan is NOT CHECKED, which is not
|
|
252
|
+
* evidence of a bypass and must not refuse on its own.
|
|
253
|
+
*
|
|
254
|
+
* Separated from the tool body so the decision can be tested without a
|
|
255
|
+
* network: the install it guards cannot be built without one. */
|
|
256
|
+
export function authorityBypassRefusal(
|
|
257
|
+
scan: AuthorityOverlap[] | null,
|
|
258
|
+
allowAuthorityOverlap: boolean | undefined
|
|
259
|
+
): string | undefined {
|
|
260
|
+
if (scan === null || allowAuthorityOverlap === true) return undefined
|
|
261
|
+
const proven = scan.filter((o) => o.severity === 'bypass')
|
|
262
|
+
if (proven.length === 0) return undefined
|
|
263
|
+
const ids = proven.map((o) => o.ruleId).join(', ')
|
|
264
|
+
// A cap bypass leaves the predicate working, so "constrains nothing" would
|
|
265
|
+
// overstate it and send the caller looking for the wrong defect.
|
|
266
|
+
const consequence = proven.every((o) => o.capBypass === true)
|
|
267
|
+
? 'This rule would install cleanly and its rolling total would not hold'
|
|
268
|
+
: 'This rule would install cleanly and constrain nothing'
|
|
269
|
+
return `install_policy: ${proven[0]?.advice ?? ''} ${consequence}, so it is refused (rule ${ids}); set \`allowAuthorityOverlap: true\` to install anyway.`
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
export async function runInstallPolicy(
|
|
240
273
|
raw: unknown
|
|
241
274
|
): Promise<ToolResponse<BuildInstallPolicyResult & { authorityScan: AuthorityOverlap[] | null }>> {
|
|
@@ -450,10 +483,73 @@ export async function runInstallPolicy(
|
|
|
450
483
|
contextType: rule.contextRuleType,
|
|
451
484
|
signers: rule.signers,
|
|
452
485
|
predicate: decodePredicate(encodedPredicate),
|
|
486
|
+
...(input.spendingLimit !== undefined
|
|
487
|
+
? {
|
|
488
|
+
spendCap: {
|
|
489
|
+
amount: input.spendingLimit.amount,
|
|
490
|
+
periodLedgers: input.spendingLimit.periodLedgers,
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
: {}),
|
|
453
494
|
},
|
|
454
495
|
existing: observed,
|
|
496
|
+
// Both addresses are pinned, so a neighbour's policies can be
|
|
497
|
+
// named rather than merely counted - which is what lets the scan
|
|
498
|
+
// say a rule has no spend cap instead of that it has something
|
|
499
|
+
// unreadable.
|
|
500
|
+
knownPolicies: {
|
|
501
|
+
interpreter: expectedInterpreter,
|
|
502
|
+
spendingLimit: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
503
|
+
},
|
|
455
504
|
})
|
|
456
|
-
|
|
505
|
+
// A `bypass` overlap is not a warning, it is a proof that this rule cannot
|
|
506
|
+
// bind the key it names: the neighbour carries NO policy, so the signer
|
|
507
|
+
// names that rule instead and the predicate never runs. Returning `ok` with
|
|
508
|
+
// the finding buried in `authorityScan` puts the whole protection on the
|
|
509
|
+
// caller reading a field, and the caller here is usually an agent that
|
|
510
|
+
// checks whether the call succeeded. Refuse, and let the caller opt in.
|
|
511
|
+
//
|
|
512
|
+
// Only the provable class. `unknown` - a neighbour whose policy this tool
|
|
513
|
+
// cannot decode - stays advisory: it may well be tighter, and refusing on
|
|
514
|
+
// "cannot decode" would block installs on a guess.
|
|
515
|
+
const bypassRefusal = authorityBypassRefusal(authorityScan, input.allowAuthorityOverlap)
|
|
516
|
+
if (bypassRefusal !== undefined) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
error: {
|
|
520
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
521
|
+
message: bypassRefusal,
|
|
522
|
+
severity: 'error',
|
|
523
|
+
retryable: false,
|
|
524
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Write the envelope here when asked, so it never travels through the
|
|
529
|
+
// caller. `writtenTo` is what the caller should hand to a signer.
|
|
530
|
+
let writtenTo: string | undefined
|
|
531
|
+
if (input.outPath !== undefined) {
|
|
532
|
+
// Write beside the target and rename, which is atomic within a
|
|
533
|
+
// filesystem. A plain write truncates first, so anything watching the
|
|
534
|
+
// directory - a signer picking up envelopes is the obvious case - can
|
|
535
|
+
// read a half-written file and report a malformed TRANSACTION. With a
|
|
536
|
+
// rename the path either does not exist or holds the whole envelope.
|
|
537
|
+
const staging = `${input.outPath}.partial`
|
|
538
|
+
await writeFile(staging, result.unsignedXdr, 'utf8')
|
|
539
|
+
const readBack = await readFile(staging, 'utf8')
|
|
540
|
+
if (readBack !== result.unsignedXdr) {
|
|
541
|
+
await rm(staging, { force: true })
|
|
542
|
+
throw new Error(
|
|
543
|
+
`outPath: wrote ${result.unsignedXdr.length} characters to ${input.outPath} but read back ${readBack.length}; the file was not persisted intact`
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
await rename(staging, input.outPath)
|
|
547
|
+
writtenTo = input.outPath
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
551
|
+
data: { ...result, authorityScan, ...(writtenTo !== undefined ? { writtenTo } : {}) },
|
|
552
|
+
}
|
|
457
553
|
} catch (e) {
|
|
458
554
|
return toolFailure('install_policy', e)
|
|
459
555
|
}
|
|
@@ -868,6 +964,7 @@ async function resolveExistingRules(
|
|
|
868
964
|
reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
|
|
869
965
|
smartAccount: input.smartAccount,
|
|
870
966
|
interpreterAddress,
|
|
967
|
+
spendingLimitAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
871
968
|
})
|
|
872
969
|
if (collected.incomplete) return null
|
|
873
970
|
return collected.rules
|
package/src/run/schemas.ts
CHANGED
|
@@ -381,6 +381,15 @@ export const ObservedRuleSchema = z.object({
|
|
|
381
381
|
signers: z.array(SignerDraftSchema),
|
|
382
382
|
policyAddresses: z.array(z.string()),
|
|
383
383
|
predicate: PredicateNodeSchema.optional(),
|
|
384
|
+
/** Parameters of an OZ spend cap already on this rule. Reporting only:
|
|
385
|
+
* whether a neighbour is capped is read from `policyAddresses`, so omitting
|
|
386
|
+
* this never turns a capped rule into an uncapped one. */
|
|
387
|
+
spendCap: z
|
|
388
|
+
.object({
|
|
389
|
+
amount: z.string().regex(/^[0-9]+$/),
|
|
390
|
+
periodLedgers: z.number().int().positive().max(U32_MAX),
|
|
391
|
+
})
|
|
392
|
+
.optional(),
|
|
384
393
|
})
|
|
385
394
|
|
|
386
395
|
const ContextRuleDraftSchema = z
|
|
@@ -713,6 +722,22 @@ export const InstallPolicyInputSchema = z
|
|
|
713
722
|
* knows. Supply it only to re-install over an existing rule, where the
|
|
714
723
|
* interpreter wants `stored_nonce + 1`. */
|
|
715
724
|
installNonce: z.number().int().positive().optional(),
|
|
725
|
+
/** Absolute path to write the unsigned envelope to.
|
|
726
|
+
*
|
|
727
|
+
* The envelope runs to several thousand characters, and without this the
|
|
728
|
+
* only route onto disk is the CALLER re-emitting it - through a model, a
|
|
729
|
+
* shell argument, or both. That transport mangles it: observed in practice
|
|
730
|
+
* as a file of the right length whose bytes no longer parse
|
|
731
|
+
* ("xdr padding contains non-zero bytes"), and as silent truncation.
|
|
732
|
+
* Writing it here takes the caller out of the transport entirely.
|
|
733
|
+
*
|
|
734
|
+
* Opt-in: omitted, nothing is written and behaviour is unchanged. */
|
|
735
|
+
outPath: z
|
|
736
|
+
.string()
|
|
737
|
+
.min(1)
|
|
738
|
+
.refine((p) => p.startsWith('/'), 'outPath must be an absolute path')
|
|
739
|
+
.refine((p) => !p.includes('\0'), 'outPath must not contain a null byte')
|
|
740
|
+
.optional(),
|
|
716
741
|
/** Optional RPC URL override. Defaults to the pinned RPC for the
|
|
717
742
|
* selected `network` (testnet by default, mainnet when
|
|
718
743
|
* `network: 'mainnet'`); the override is refused unless
|
|
@@ -756,6 +781,18 @@ export const InstallPolicyInputSchema = z
|
|
|
756
781
|
* unaffected; only the case the synthesizer explicitly flagged is
|
|
757
782
|
* refused. */
|
|
758
783
|
allowUnboundedAmount: z.boolean().optional(),
|
|
784
|
+
/** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
|
|
785
|
+
*
|
|
786
|
+
* An OZ account resolves a call against the rule the caller NAMES, so a
|
|
787
|
+
* key on several rules gets the MAXIMUM authority over them, never the
|
|
788
|
+
* intersection. A key that also sits on a rule with no policy is therefore
|
|
789
|
+
* unconstrained: it names that rule and the new predicate never runs.
|
|
790
|
+
*
|
|
791
|
+
* Default-deny, and only for the case the scan can PROVE - an unpoliced
|
|
792
|
+
* neighbour sharing signers and selectors. A neighbour carrying a policy
|
|
793
|
+
* this tool cannot decode stays advisory, because "cannot decode" is not
|
|
794
|
+
* "proved unsafe" and refusing it would block installs on a guess. */
|
|
795
|
+
allowAuthorityOverlap: z.boolean().optional(),
|
|
759
796
|
/** Opt-in to pointing the rule's interpreter policy at any address
|
|
760
797
|
* other than the pinned interpreter for the selected network.
|
|
761
798
|
* Default-deny: a caller that controls the interpreter can permit
|