@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.
@@ -24,6 +24,24 @@ export type ContextType = {
24
24
  * - `unpoliced`: no policy at all. Whatever its context type allows, its
25
25
  * signers may do without constraint. */
26
26
  export type RuleClass = 'interpreter' | 'foreign' | 'unpoliced';
27
+ /** An OZ `spending_limit`'s parameters. `amount` is in the token's smallest
28
+ * unit; the period is a LEDGER count, not seconds. */
29
+ export interface SpendCap {
30
+ amount: string;
31
+ periodLedgers: number;
32
+ }
33
+ /** The policy contracts this tool can recognise by address. Supplying them lets
34
+ * the scan reason about a neighbour's SPEND CAP instead of treating any
35
+ * non-interpreter policy as opaque.
36
+ *
37
+ * Recognition is what licenses the strong conclusion. "This rule has no spend
38
+ * cap" is only sound when every policy on it is accounted for; a single
39
+ * unrecognised address could be somebody else's cap, so such a rule stays
40
+ * advisory. */
41
+ export interface KnownPolicies {
42
+ interpreter: string;
43
+ spendingLimit: string;
44
+ }
27
45
  export interface ObservedRule {
28
46
  id: number;
29
47
  contextType: ContextType;
@@ -33,6 +51,11 @@ export interface ObservedRule {
33
51
  /** Decoded predicate. Present only when the rule is policed by OUR
34
52
  * interpreter and the stored document was readable. */
35
53
  predicate?: PredicateNode;
54
+ /** The attached spend cap's parameters, when the reader could read them from
55
+ * the policy's own storage. Attachment is decided from `policyAddresses`, so
56
+ * this being absent does NOT mean the rule is uncapped - only that the
57
+ * numbers are unknown. */
58
+ spendCap?: SpendCap;
36
59
  }
37
60
  export interface IntendedInstall {
38
61
  /** Rule the predicate is being installed onto. A re-install onto the same
@@ -42,9 +65,15 @@ export interface IntendedInstall {
42
65
  contextType: ContextType;
43
66
  signers: SignerDraft[];
44
67
  predicate: PredicateNode;
68
+ /** The rolling cap being installed alongside the predicate, when one is.
69
+ * Its presence is what makes a neighbour's LACK of a cap a finding: without
70
+ * it there is no total for a neighbour to route around. */
71
+ spendCap?: SpendCap;
45
72
  }
46
73
  export type OverlapSeverity =
47
- /** A neighbouring rule imposes no constraint at all on the shared calls. */
74
+ /** A neighbouring rule imposes no constraint at all on the shared calls, or
75
+ * imposes no ROLLING TOTAL on calls the new rule caps. Either way the new
76
+ * rule's bound does not hold for a signer who can name this one. */
48
77
  'bypass'
49
78
  /** A neighbouring policy exists but what it permits cannot be read. */
50
79
  | 'unknown'
@@ -60,6 +89,14 @@ export interface AuthorityOverlap {
60
89
  sharedSigners: SignerDraft[];
61
90
  /** The selectors both rules can serve. Non-empty by construction. */
62
91
  sharedSelectors: Selector[];
92
+ /** This neighbour's own rolling cap, when it has one this tool could read.
93
+ * A spend cap is keyed by (account, RULE id), so two capped rules do not
94
+ * share a budget - a signer on both may spend the SUM. */
95
+ spendCap?: SpendCap;
96
+ /** True when the new rule installs a rolling total and this neighbour serves
97
+ * some of the same calls WITHOUT one, which voids the total rather than
98
+ * merely widening it. Only set when every policy here was recognised. */
99
+ capBypass?: true;
63
100
  advice: string;
64
101
  }
65
102
  /** Canonical key for signer equality. Mirrors OZ's `Signer` enum: a delegated
@@ -98,4 +135,7 @@ export declare function effectiveSelectors(rule: ObservedRule): Selector[];
98
135
  export declare function findAuthorityOverlaps(args: {
99
136
  intended: IntendedInstall;
100
137
  existing: ObservedRule[];
138
+ /** Omit to skip spend-cap reasoning entirely: every neighbour is then judged
139
+ * exactly as before, on its policies' presence rather than their meaning. */
140
+ knownPolicies?: KnownPolicies;
101
141
  }): AuthorityOverlap[];
@@ -191,14 +191,38 @@ function classifyRule(rule) {
191
191
  return 'unpoliced';
192
192
  return rule.predicate ? 'interpreter' : 'foreign';
193
193
  }
194
- function adviceFor(cls, ruleId) {
194
+ /** Is the OZ spend cap attached to this rule? Decided by ADDRESS, so it holds
195
+ * whether or not the cap's parameters could be read. */
196
+ function hasSpendCap(rule, known) {
197
+ return rule.policyAddresses.includes(known.spendingLimit);
198
+ }
199
+ /** Every policy on the rule is one this tool knows the semantics of, so
200
+ * "there is no spend cap here" is an observation rather than an assumption. */
201
+ function allPoliciesRecognised(rule, known) {
202
+ return rule.policyAddresses.every((addr) => addr === known.interpreter || addr === known.spendingLimit);
203
+ }
204
+ function capBypassAdvice(ruleId) {
205
+ return `the rolling total you are installing will not bind: rule ${ruleId} serves some of the same calls for a shared signer and carries NO spend cap, so that signer spends through it without one. A cap is stored per RULE, never per key. Remove the shared signer from rule ${ruleId}, put an equivalent cap on it, or narrow it so it no longer serves these calls.`;
206
+ }
207
+ /** How a signer's spend adds up across two capped rules. Same period or not,
208
+ * the budgets are separate; saying so with the numbers beats saying it in the
209
+ * abstract, which is what the caller has to reason about. */
210
+ function combinedCapNote(mine, theirs) {
211
+ if (mine.periodLedgers === theirs.periodLedgers) {
212
+ const total = (BigInt(mine.amount) + BigInt(theirs.amount)).toString();
213
+ return ` Its cap is ${theirs.amount} over the same ${theirs.periodLedgers}-ledger period as yours, and the two budgets are separate, so a shared signer may spend ${total} in total.`;
214
+ }
215
+ return ` Its cap is ${theirs.amount} over ${theirs.periodLedgers} ledgers against your ${mine.amount} over ${mine.periodLedgers}; the periods differ, so the two budgets neither share nor cancel and a shared signer draws on both.`;
216
+ }
217
+ function adviceFor(cls, ruleId, theirCap, myCap) {
218
+ const capNote = theirCap !== undefined && myCap !== undefined ? combinedCapNote(myCap, theirCap) : '';
195
219
  switch (cls) {
196
220
  case 'unpoliced':
197
221
  return `rule ${ruleId} has no policy attached, so a shared signer may make these calls with no constraint at all - the predicate you are installing will never run for them. Remove the shared signer from rule ${ruleId}, or attach a policy to it.`;
198
222
  case 'foreign':
199
- return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule.`;
223
+ return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule.${capNote}`;
200
224
  case 'interpreter':
201
- return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other.`;
225
+ return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other.${capNote}`;
202
226
  }
203
227
  }
204
228
  /**
@@ -222,17 +246,42 @@ export function findAuthorityOverlaps(args) {
222
246
  if (sharedSelectors.length === 0)
223
247
  continue;
224
248
  const ruleClass = classifyRule(rule);
249
+ // A rolling total is keyed by (account, rule id), so it constrains THIS
250
+ // rule and nothing else. If the new rule carries one and a neighbour serves
251
+ // the same calls without one, the signer names the neighbour and spends
252
+ // without limit - the total was never a bound on the key. Proven on testnet
253
+ // in `docs/audit/evidence/oz-two-rule-blend-cap.log`, where an uncapped
254
+ // sibling rule passed the very amount the capped rule refused.
255
+ //
256
+ // Sound only when every policy on the neighbour was recognised. One
257
+ // unrecognised address could be another spend cap, and refusing an install
258
+ // over a policy we cannot read would be a guess, not a proof.
259
+ // An unpoliced neighbour is excluded deliberately. It has no policies, so
260
+ // it passes the "everything recognised, no cap" test vacuously - but the
261
+ // finding there is not that a total leaks, it is that NOTHING constrains
262
+ // those calls. Reporting the narrower cause would send the reader looking
263
+ // for a spend cap when the rule needs a policy at all.
264
+ const capBypass = ruleClass !== 'unpoliced' &&
265
+ args.knownPolicies !== undefined &&
266
+ args.intended.spendCap !== undefined &&
267
+ allPoliciesRecognised(rule, args.knownPolicies) &&
268
+ !hasSpendCap(rule, args.knownPolicies);
269
+ const severity = ruleClass === 'unpoliced' || capBypass
270
+ ? 'bypass'
271
+ : ruleClass === 'foreign'
272
+ ? 'unknown'
273
+ : 'not-restricting';
225
274
  out.push({
226
275
  ruleId: rule.id,
227
276
  ruleClass,
228
- severity: ruleClass === 'unpoliced'
229
- ? 'bypass'
230
- : ruleClass === 'foreign'
231
- ? 'unknown'
232
- : 'not-restricting',
277
+ severity,
233
278
  sharedSigners: shared,
234
279
  sharedSelectors,
235
- advice: adviceFor(ruleClass, rule.id),
280
+ ...(rule.spendCap !== undefined ? { spendCap: rule.spendCap } : {}),
281
+ ...(capBypass ? { capBypass: true } : {}),
282
+ advice: capBypass
283
+ ? capBypassAdvice(rule.id)
284
+ : adviceFor(ruleClass, rule.id, rule.spendCap, args.intended.spendCap),
236
285
  });
237
286
  }
238
287
  return out;
@@ -1,6 +1,6 @@
1
1
  import { rpc, xdr } from '@stellar/stellar-sdk';
2
2
  import type { SignerDraft } from '../types.ts';
3
- import type { ContextType, ObservedRule } from './authority-overlap.ts';
3
+ import type { ContextType, ObservedRule, SpendCap } from './authority-overlap.ts';
4
4
  /** `storage.rs` - the third element of the persistent doc key tuple. */
5
5
  export declare const K_DOC = 1;
6
6
  /** Persistent-storage key for a rule's stored document:
@@ -19,6 +19,20 @@ export declare function decodeSigner(v: xdr.ScVal): SignerDraft | undefined;
19
19
  export declare function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined;
20
20
  /** The interpreter's `StoredDoc { predicate_bytes }`. */
21
21
  export declare function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined;
22
+ /** Persistent-storage key for a rule's spend-cap data:
23
+ * `SpendingLimitStorageKey::AccountContext(account, rule_id)`, which the host
24
+ * encodes as an enum variant - the symbol first, then the payload. */
25
+ export declare function spendCapKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal;
26
+ /** Ledger key for the OZ spend cap's own persistent entry. The policy exposes
27
+ * `get_spending_limit_data`, but that PANICS when nothing is installed, and a
28
+ * panic is indistinguishable from an RPC fault at the call site. Reading the
29
+ * entry lets "no cap here" come back as an absence instead. */
30
+ export declare function spendCapLedgerKey(spendingLimit: string, smartAccount: string, ruleId: number): xdr.LedgerKey;
31
+ /** `SpendingLimitData`, of which only the two installed parameters matter here.
32
+ * The running total and the history are deliberately ignored: they say what
33
+ * has been spent so far, which changes every call, while the scan is about
34
+ * what the rule PERMITS. */
35
+ export declare function decodeSpendCap(v: xdr.ScVal): SpendCap | undefined;
22
36
  /** The three reads the scan needs. Kept as an interface so the collection
23
37
  * below is testable without a network. */
24
38
  export interface AccountRuleReader {
@@ -29,6 +43,10 @@ export interface AccountRuleReader {
29
43
  /** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
30
44
  * Undefined when no document is stored for that rule. */
31
45
  getStoredDoc(interpreter: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
46
+ /** The OZ spend cap's persistent entry for this rule, read as a ledger entry.
47
+ * Optional so an existing reader keeps working: without it the scan simply
48
+ * reports no cap parameters, which is the same as it behaved before. */
49
+ getSpendCapData?(spendingLimit: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
32
50
  }
33
51
  /** How far the id scan will probe before giving up. OZ imposes no per-account
34
52
  * rule cap, so there is no exact bound to derive; this one is far above any
@@ -64,6 +82,10 @@ export declare function collectObservedRules(args: {
64
82
  reader: AccountRuleReader;
65
83
  smartAccount: string;
66
84
  interpreterAddress: string;
85
+ /** The pinned OZ spend cap. Supplying it fills in the PARAMETERS of a
86
+ * neighbour's cap; whether one is attached at all is decided from the
87
+ * rule's policy addresses and does not depend on this read succeeding. */
88
+ spendingLimitAddress?: string;
67
89
  maxRuleIdScan?: number;
68
90
  }): Promise<CollectedRules>;
69
91
  /**
@@ -19,7 +19,7 @@
19
19
  //!
20
20
  //! The decoders are pure so they can be tested without a network; the caller
21
21
  //! supplies raw `ScVal`s.
22
- import { Account, Address, BASE_FEE, Contract, Keypair, rpc, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
22
+ import { Account, Address, BASE_FEE, Contract, Keypair, rpc, scValToNative, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
23
23
  import { decodePredicate } from "../predicate/decode.js";
24
24
  /** `storage.rs` - the third element of the persistent doc key tuple. */
25
25
  export const K_DOC = 1;
@@ -147,6 +147,43 @@ export function decodeStoredPredicateBytes(v) {
147
147
  return undefined;
148
148
  return field.bytes();
149
149
  }
150
+ /** Persistent-storage key for a rule's spend-cap data:
151
+ * `SpendingLimitStorageKey::AccountContext(account, rule_id)`, which the host
152
+ * encodes as an enum variant - the symbol first, then the payload. */
153
+ export function spendCapKeyScVal(smartAccount, ruleId) {
154
+ return xdr.ScVal.scvVec([
155
+ xdr.ScVal.scvSymbol('AccountContext'),
156
+ new Address(smartAccount).toScVal(),
157
+ xdr.ScVal.scvU32(ruleId),
158
+ ]);
159
+ }
160
+ /** Ledger key for the OZ spend cap's own persistent entry. The policy exposes
161
+ * `get_spending_limit_data`, but that PANICS when nothing is installed, and a
162
+ * panic is indistinguishable from an RPC fault at the call site. Reading the
163
+ * entry lets "no cap here" come back as an absence instead. */
164
+ export function spendCapLedgerKey(spendingLimit, smartAccount, ruleId) {
165
+ return xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
166
+ contract: new Address(spendingLimit).toScAddress(),
167
+ key: spendCapKeyScVal(smartAccount, ruleId),
168
+ durability: xdr.ContractDataDurability.persistent(),
169
+ }));
170
+ }
171
+ /** `SpendingLimitData`, of which only the two installed parameters matter here.
172
+ * The running total and the history are deliberately ignored: they say what
173
+ * has been spent so far, which changes every call, while the scan is about
174
+ * what the rule PERMITS. */
175
+ export function decodeSpendCap(v) {
176
+ const periodLedgers = u32Of(mapField(v, 'period_ledgers'));
177
+ const limit = mapField(v, 'spending_limit');
178
+ if (periodLedgers === undefined || !limit)
179
+ return undefined;
180
+ if (limit.switch() !== xdr.ScValType.scvI128())
181
+ return undefined;
182
+ const amount = scValToNative(limit);
183
+ if (typeof amount !== 'bigint')
184
+ return undefined;
185
+ return { amount: amount.toString(), periodLedgers };
186
+ }
150
187
  /** How far the id scan will probe before giving up. OZ imposes no per-account
151
188
  * rule cap, so there is no exact bound to derive; this one is far above any
152
189
  * realistic account and keeps a malformed `Count` from spinning forever. */
@@ -195,6 +232,16 @@ export async function collectObservedRules(args) {
195
232
  unreadablePredicateRuleIds.push(rule.id);
196
233
  }
197
234
  }
235
+ const spendCapAddress = args.spendingLimitAddress;
236
+ if (spendCapAddress !== undefined && rule.policyAddresses.includes(spendCapAddress)) {
237
+ const data = await args.reader.getSpendCapData?.(spendCapAddress, args.smartAccount, rule.id);
238
+ const cap = data ? decodeSpendCap(data) : undefined;
239
+ // An unreadable cap is left absent rather than guessed at. The rule still
240
+ // counts as capped, because attachment came from its policy addresses,
241
+ // so failing this read weakens the REPORT and never the refusal.
242
+ if (cap)
243
+ rule.spendCap = cap;
244
+ }
198
245
  rules.push(rule);
199
246
  }
200
247
  return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count };
@@ -237,5 +284,13 @@ export function accountRuleReaderFromServer(server, networkPassphrase) {
237
284
  return undefined;
238
285
  return entry.contractData().val();
239
286
  },
287
+ async getSpendCapData(spendingLimit, smartAccount, ruleId) {
288
+ const key = spendCapLedgerKey(spendingLimit, smartAccount, ruleId);
289
+ const res = await server.getLedgerEntries(key);
290
+ const entry = res.entries?.[0]?.val;
291
+ if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData())
292
+ return undefined;
293
+ return entry.contractData().val();
294
+ },
240
295
  };
241
296
  }
@@ -38,6 +38,23 @@ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<
38
38
  predicateTree: PredicateNode | null;
39
39
  };
40
40
  }>;
41
+ /** The refusal message for an install the cross-rule scan proves cannot bind,
42
+ * or `undefined` when the install may proceed.
43
+ *
44
+ * Only `bypass` refuses, and it covers two proofs. Either the neighbouring
45
+ * rule carries NO policy, so a shared signer names it and the new predicate
46
+ * never runs; or the install carries a rolling total and a fully recognised
47
+ * neighbour serves the same calls without one, so the total is not a bound on
48
+ * the key. Both are proofs from data already in hand.
49
+ *
50
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
51
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
52
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
53
+ * evidence of a bypass and must not refuse on its own.
54
+ *
55
+ * Separated from the tool body so the decision can be tested without a
56
+ * network: the install it guards cannot be built without one. */
57
+ export declare function authorityBypassRefusal(scan: AuthorityOverlap[] | null, allowAuthorityOverlap: boolean | undefined): string | undefined;
41
58
  export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<BuildInstallPolicyResult & {
42
59
  authorityScan: AuthorityOverlap[] | null;
43
60
  }>>;
package/dist/run/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // No business logic. No retries. No session state. The same call shape can
18
18
  // drive the CLI (which calls into the same core directly without MCP).
19
19
  import { createHash } from 'node:crypto';
20
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises';
20
21
  import { rpc } from '@stellar/stellar-sdk';
21
22
  import { PLACEHOLDER_INTERPRETER_ADDRESS } from "../adapters/interpreter/adapter.js";
22
23
  import { declarePredicate, encodePredicate, recordTransaction, synthesizeFromRecording, } from "../index.js";
@@ -128,6 +129,36 @@ export async function runSynthesizePolicy(raw) {
128
129
  return toolFailure('synthesize_policy', e);
129
130
  }
130
131
  }
132
+ /** The refusal message for an install the cross-rule scan proves cannot bind,
133
+ * or `undefined` when the install may proceed.
134
+ *
135
+ * Only `bypass` refuses, and it covers two proofs. Either the neighbouring
136
+ * rule carries NO policy, so a shared signer names it and the new predicate
137
+ * never runs; or the install carries a rolling total and a fully recognised
138
+ * neighbour serves the same calls without one, so the total is not a bound on
139
+ * the key. Both are proofs from data already in hand.
140
+ *
141
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
142
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
143
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
144
+ * evidence of a bypass and must not refuse on its own.
145
+ *
146
+ * Separated from the tool body so the decision can be tested without a
147
+ * network: the install it guards cannot be built without one. */
148
+ export function authorityBypassRefusal(scan, allowAuthorityOverlap) {
149
+ if (scan === null || allowAuthorityOverlap === true)
150
+ return undefined;
151
+ const proven = scan.filter((o) => o.severity === 'bypass');
152
+ if (proven.length === 0)
153
+ return undefined;
154
+ const ids = proven.map((o) => o.ruleId).join(', ');
155
+ // A cap bypass leaves the predicate working, so "constrains nothing" would
156
+ // overstate it and send the caller looking for the wrong defect.
157
+ const consequence = proven.every((o) => o.capBypass === true)
158
+ ? 'This rule would install cleanly and its rolling total would not hold'
159
+ : 'This rule would install cleanly and constrain nothing';
160
+ return `install_policy: ${proven[0]?.advice ?? ''} ${consequence}, so it is refused (rule ${ids}); set \`allowAuthorityOverlap: true\` to install anyway.`;
161
+ }
131
162
  export async function runInstallPolicy(raw) {
132
163
  const parsed = InstallPolicyInputSchema.safeParse(raw);
133
164
  if (!parsed.success) {
@@ -327,10 +358,71 @@ export async function runInstallPolicy(raw) {
327
358
  contextType: rule.contextRuleType,
328
359
  signers: rule.signers,
329
360
  predicate: decodePredicate(encodedPredicate),
361
+ ...(input.spendingLimit !== undefined
362
+ ? {
363
+ spendCap: {
364
+ amount: input.spendingLimit.amount,
365
+ periodLedgers: input.spendingLimit.periodLedgers,
366
+ },
367
+ }
368
+ : {}),
330
369
  },
331
370
  existing: observed,
371
+ // Both addresses are pinned, so a neighbour's policies can be
372
+ // named rather than merely counted - which is what lets the scan
373
+ // say a rule has no spend cap instead of that it has something
374
+ // unreadable.
375
+ knownPolicies: {
376
+ interpreter: expectedInterpreter,
377
+ spendingLimit: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
378
+ },
332
379
  });
333
- return { ok: true, data: { ...result, authorityScan } };
380
+ // A `bypass` overlap is not a warning, it is a proof that this rule cannot
381
+ // bind the key it names: the neighbour carries NO policy, so the signer
382
+ // names that rule instead and the predicate never runs. Returning `ok` with
383
+ // the finding buried in `authorityScan` puts the whole protection on the
384
+ // caller reading a field, and the caller here is usually an agent that
385
+ // checks whether the call succeeded. Refuse, and let the caller opt in.
386
+ //
387
+ // Only the provable class. `unknown` - a neighbour whose policy this tool
388
+ // cannot decode - stays advisory: it may well be tighter, and refusing on
389
+ // "cannot decode" would block installs on a guess.
390
+ const bypassRefusal = authorityBypassRefusal(authorityScan, input.allowAuthorityOverlap);
391
+ if (bypassRefusal !== undefined) {
392
+ return {
393
+ ok: false,
394
+ error: {
395
+ code: 'INSTALL_BUILD_FAILED',
396
+ message: bypassRefusal,
397
+ severity: 'error',
398
+ retryable: false,
399
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
400
+ },
401
+ };
402
+ }
403
+ // Write the envelope here when asked, so it never travels through the
404
+ // caller. `writtenTo` is what the caller should hand to a signer.
405
+ let writtenTo;
406
+ if (input.outPath !== undefined) {
407
+ // Write beside the target and rename, which is atomic within a
408
+ // filesystem. A plain write truncates first, so anything watching the
409
+ // directory - a signer picking up envelopes is the obvious case - can
410
+ // read a half-written file and report a malformed TRANSACTION. With a
411
+ // rename the path either does not exist or holds the whole envelope.
412
+ const staging = `${input.outPath}.partial`;
413
+ await writeFile(staging, result.unsignedXdr, 'utf8');
414
+ const readBack = await readFile(staging, 'utf8');
415
+ if (readBack !== result.unsignedXdr) {
416
+ await rm(staging, { force: true });
417
+ throw new Error(`outPath: wrote ${result.unsignedXdr.length} characters to ${input.outPath} but read back ${readBack.length}; the file was not persisted intact`);
418
+ }
419
+ await rename(staging, input.outPath);
420
+ writtenTo = input.outPath;
421
+ }
422
+ return {
423
+ ok: true,
424
+ data: { ...result, authorityScan, ...(writtenTo !== undefined ? { writtenTo } : {}) },
425
+ };
334
426
  }
335
427
  catch (e) {
336
428
  return toolFailure('install_policy', e);
@@ -708,6 +800,7 @@ async function resolveExistingRules(input, network, interpreterAddress) {
708
800
  reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
709
801
  smartAccount: input.smartAccount,
710
802
  interpreterAddress,
803
+ spendingLimitAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
711
804
  });
712
805
  if (collected.incomplete)
713
806
  return null;