@crediolabs/policy-synth 1.1.0 → 1.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.
@@ -112,6 +112,21 @@ export interface InstallCallDescribes {
112
112
  export interface BuildInstallPolicyResult {
113
113
  /** Unsigned Soroban transaction envelope, base64 XDR. */
114
114
  unsignedXdr: string;
115
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
116
+ * prove it arrived whole.
117
+ *
118
+ * This envelope runs to several thousand characters, and the only route
119
+ * from a tool result onto disk is the caller re-emitting it. A truncated
120
+ * copy is not obviously wrong - it fails later as
121
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
122
+ * transaction rather than a transport problem. Observed in practice: one of
123
+ * two envelopes written in the same session lost its tail and its base64
124
+ * length went from a multiple of four to `len % 4 == 3`.
125
+ *
126
+ * Check both before signing. They are cheap, and they turn a silent,
127
+ * fatal truncation into a retry. */
128
+ unsignedXdrLength: number;
129
+ unsignedXdrSha256: string;
115
130
  /** Smart account contract address (echo). */
116
131
  smartAccount: string;
117
132
  /** Source account (echo) - the address that must sign. */
@@ -168,6 +183,11 @@ export declare function buildRevokePolicyXdr(args: {
168
183
  }): Promise<BuildRevokePolicyResult>;
169
184
  export interface BuildRevokePolicyResult {
170
185
  unsignedXdr: string;
186
+ /** Same integrity pair as the install result, for the same reason: a revoke
187
+ * envelope also has to reach a signer intact, and a truncated copy fails as
188
+ * a malformed transaction rather than as a transport error. */
189
+ unsignedXdrLength: number;
190
+ unsignedXdrSha256: string;
171
191
  smartAccount: string;
172
192
  sourceAccount: string;
173
193
  call: {
@@ -62,6 +62,14 @@ export function rpcClientFromServer(server, networkPassphrase) {
62
62
  },
63
63
  };
64
64
  }
65
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
66
+ function xdrIntegrity(unsignedXdr) {
67
+ return {
68
+ unsignedXdr,
69
+ unsignedXdrLength: unsignedXdr.length,
70
+ unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
71
+ };
72
+ }
65
73
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
66
74
  * The output XDR is signed by the wallet, not by us. */
67
75
  export async function buildInstallPolicyXdr(args) {
@@ -90,7 +98,7 @@ export async function buildInstallPolicyXdr(args) {
90
98
  // The human approval binds to the exact bytes the wallet will sign.
91
99
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
92
100
  return {
93
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
101
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
94
102
  smartAccount: args.smartAccount,
95
103
  sourceAccount: args.sourceAccount,
96
104
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -119,7 +127,7 @@ export async function buildRevokePolicyXdr(args) {
119
127
  // consumer supplies only the ordinary envelope signature.
120
128
  const { finalTx, original, validUntilLedger } = await buildAuthorisedSmartAccountTx(args, 'remove_context_rule', [xdr.ScVal.scvU32(args.ruleId)], 'revoke_policy');
121
129
  return {
122
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
130
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
123
131
  smartAccount: args.smartAccount,
124
132
  sourceAccount: args.sourceAccount,
125
133
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -38,6 +38,20 @@ 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. That class means the neighbouring rule carries NO
45
+ * policy, so a shared signer names it and the new predicate never runs - a
46
+ * proof, from data already in hand, that the rule constrains nothing.
47
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
48
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
49
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
50
+ * evidence of a bypass and must not refuse on its own.
51
+ *
52
+ * Separated from the tool body so the decision can be tested without a
53
+ * network: the install it guards cannot be built without one. */
54
+ export declare function authorityBypassRefusal(scan: AuthorityOverlap[] | null, allowAuthorityOverlap: boolean | undefined): string | undefined;
41
55
  export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<BuildInstallPolicyResult & {
42
56
  authorityScan: AuthorityOverlap[] | null;
43
57
  }>>;
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,28 @@ 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. That class means the neighbouring rule carries NO
136
+ * policy, so a shared signer names it and the new predicate never runs - a
137
+ * proof, from data already in hand, that the rule constrains nothing.
138
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
139
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
140
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
141
+ * evidence of a bypass and must not refuse on its own.
142
+ *
143
+ * Separated from the tool body so the decision can be tested without a
144
+ * network: the install it guards cannot be built without one. */
145
+ export function authorityBypassRefusal(scan, allowAuthorityOverlap) {
146
+ if (scan === null || allowAuthorityOverlap === true)
147
+ return undefined;
148
+ const proven = scan.filter((o) => o.severity === 'bypass');
149
+ if (proven.length === 0)
150
+ return undefined;
151
+ const ids = proven.map((o) => o.ruleId).join(', ');
152
+ return `install_policy: ${proven[0]?.advice ?? ''} This rule would install cleanly and constrain nothing, so it is refused (rule ${ids}); remove the shared signer from that rule, attach a policy to it, or set \`allowAuthorityOverlap: true\` to install anyway.`;
153
+ }
131
154
  export async function runInstallPolicy(raw) {
132
155
  const parsed = InstallPolicyInputSchema.safeParse(raw);
133
156
  if (!parsed.success) {
@@ -330,7 +353,52 @@ export async function runInstallPolicy(raw) {
330
353
  },
331
354
  existing: observed,
332
355
  });
333
- return { ok: true, data: { ...result, authorityScan } };
356
+ // A `bypass` overlap is not a warning, it is a proof that this rule cannot
357
+ // bind the key it names: the neighbour carries NO policy, so the signer
358
+ // names that rule instead and the predicate never runs. Returning `ok` with
359
+ // the finding buried in `authorityScan` puts the whole protection on the
360
+ // caller reading a field, and the caller here is usually an agent that
361
+ // checks whether the call succeeded. Refuse, and let the caller opt in.
362
+ //
363
+ // Only the provable class. `unknown` - a neighbour whose policy this tool
364
+ // cannot decode - stays advisory: it may well be tighter, and refusing on
365
+ // "cannot decode" would block installs on a guess.
366
+ const bypassRefusal = authorityBypassRefusal(authorityScan, input.allowAuthorityOverlap);
367
+ if (bypassRefusal !== undefined) {
368
+ return {
369
+ ok: false,
370
+ error: {
371
+ code: 'INSTALL_BUILD_FAILED',
372
+ message: bypassRefusal,
373
+ severity: 'error',
374
+ retryable: false,
375
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
376
+ },
377
+ };
378
+ }
379
+ // Write the envelope here when asked, so it never travels through the
380
+ // caller. `writtenTo` is what the caller should hand to a signer.
381
+ let writtenTo;
382
+ if (input.outPath !== undefined) {
383
+ // Write beside the target and rename, which is atomic within a
384
+ // filesystem. A plain write truncates first, so anything watching the
385
+ // directory - a signer picking up envelopes is the obvious case - can
386
+ // read a half-written file and report a malformed TRANSACTION. With a
387
+ // rename the path either does not exist or holds the whole envelope.
388
+ const staging = `${input.outPath}.partial`;
389
+ await writeFile(staging, result.unsignedXdr, 'utf8');
390
+ const readBack = await readFile(staging, 'utf8');
391
+ if (readBack !== result.unsignedXdr) {
392
+ await rm(staging, { force: true });
393
+ throw new Error(`outPath: wrote ${result.unsignedXdr.length} characters to ${input.outPath} but read back ${readBack.length}; the file was not persisted intact`);
394
+ }
395
+ await rename(staging, input.outPath);
396
+ writtenTo = input.outPath;
397
+ }
398
+ return {
399
+ ok: true,
400
+ data: { ...result, authorityScan, ...(writtenTo !== undefined ? { writtenTo } : {}) },
401
+ };
334
402
  }
335
403
  catch (e) {
336
404
  return toolFailure('install_policy', e);
@@ -2849,10 +2849,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2849
2849
  predicate?: unknown;
2850
2850
  }>, "many">>;
2851
2851
  /** The smart account contract address (C...) that will receive the rule. */
2852
- smartAccount: z.ZodString;
2852
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
2853
2853
  /** The signer that authorises the install (G... wallet). Used only for
2854
2854
  * sequence number + auth nonce simulation; never persisted, never signed. */
2855
- sourceAccount: z.ZodString;
2855
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
2856
2856
  /** Target network for the install. Selects which interpreter pin and
2857
2857
  * which RPC URL are valid by default. Defaults to `testnet` so the
2858
2858
  * pre-mainnet callers keep working: they were always pointing at
@@ -3366,6 +3366,17 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3366
3366
  * knows. Supply it only to re-install over an existing rule, where the
3367
3367
  * interpreter wants `stored_nonce + 1`. */
3368
3368
  installNonce: z.ZodOptional<z.ZodNumber>;
3369
+ /** Absolute path to write the unsigned envelope to.
3370
+ *
3371
+ * The envelope runs to several thousand characters, and without this the
3372
+ * only route onto disk is the CALLER re-emitting it - through a model, a
3373
+ * shell argument, or both. That transport mangles it: observed in practice
3374
+ * as a file of the right length whose bytes no longer parse
3375
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
3376
+ * Writing it here takes the caller out of the transport entirely.
3377
+ *
3378
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
3379
+ outPath: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
3369
3380
  /** Optional RPC URL override. Defaults to the pinned RPC for the
3370
3381
  * selected `network` (testnet by default, mainnet when
3371
3382
  * `network: 'mainnet'`); the override is refused unless
@@ -3411,6 +3422,18 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3411
3422
  * unaffected; only the case the synthesizer explicitly flagged is
3412
3423
  * refused. */
3413
3424
  allowUnboundedAmount: z.ZodOptional<z.ZodBoolean>;
3425
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
3426
+ *
3427
+ * An OZ account resolves a call against the rule the caller NAMES, so a
3428
+ * key on several rules gets the MAXIMUM authority over them, never the
3429
+ * intersection. A key that also sits on a rule with no policy is therefore
3430
+ * unconstrained: it names that rule and the new predicate never runs.
3431
+ *
3432
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
3433
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
3434
+ * this tool cannot decode stays advisory, because "cannot decode" is not
3435
+ * "proved unsafe" and refusing it would block installs on a guess. */
3436
+ allowAuthorityOverlap: z.ZodOptional<z.ZodBoolean>;
3414
3437
  /** Opt-in to pointing the rule's interpreter policy at any address
3415
3438
  * other than the pinned interpreter for the selected network.
3416
3439
  * Default-deny: a caller that controls the interpreter can permit
@@ -3549,9 +3572,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3549
3572
  validUntilLedger?: number | undefined;
3550
3573
  name?: string | undefined;
3551
3574
  } | undefined;
3575
+ outPath?: string | undefined;
3552
3576
  rpcUrl?: string | undefined;
3553
3577
  allowUnpinnedRpcUrl?: boolean | undefined;
3554
3578
  allowUnboundedAmount?: boolean | undefined;
3579
+ allowAuthorityOverlap?: boolean | undefined;
3555
3580
  allowUnpinnedInterpreter?: boolean | undefined;
3556
3581
  baseFee?: number | undefined;
3557
3582
  }, {
@@ -3684,9 +3709,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3684
3709
  validUntilLedger?: number | undefined;
3685
3710
  name?: string | undefined;
3686
3711
  } | undefined;
3712
+ outPath?: string | undefined;
3687
3713
  rpcUrl?: string | undefined;
3688
3714
  allowUnpinnedRpcUrl?: boolean | undefined;
3689
3715
  allowUnboundedAmount?: boolean | undefined;
3716
+ allowAuthorityOverlap?: boolean | undefined;
3690
3717
  allowUnpinnedInterpreter?: boolean | undefined;
3691
3718
  baseFee?: number | undefined;
3692
3719
  }>, {
@@ -3819,9 +3846,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3819
3846
  validUntilLedger?: number | undefined;
3820
3847
  name?: string | undefined;
3821
3848
  } | undefined;
3849
+ outPath?: string | undefined;
3822
3850
  rpcUrl?: string | undefined;
3823
3851
  allowUnpinnedRpcUrl?: boolean | undefined;
3824
3852
  allowUnboundedAmount?: boolean | undefined;
3853
+ allowAuthorityOverlap?: boolean | undefined;
3825
3854
  allowUnpinnedInterpreter?: boolean | undefined;
3826
3855
  baseFee?: number | undefined;
3827
3856
  }, {
@@ -3954,9 +3983,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3954
3983
  validUntilLedger?: number | undefined;
3955
3984
  name?: string | undefined;
3956
3985
  } | undefined;
3986
+ outPath?: string | undefined;
3957
3987
  rpcUrl?: string | undefined;
3958
3988
  allowUnpinnedRpcUrl?: boolean | undefined;
3959
3989
  allowUnboundedAmount?: boolean | undefined;
3990
+ allowAuthorityOverlap?: boolean | undefined;
3960
3991
  allowUnpinnedInterpreter?: boolean | undefined;
3961
3992
  baseFee?: number | undefined;
3962
3993
  }>, {
@@ -4089,9 +4120,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4089
4120
  validUntilLedger?: number | undefined;
4090
4121
  name?: string | undefined;
4091
4122
  } | undefined;
4123
+ outPath?: string | undefined;
4092
4124
  rpcUrl?: string | undefined;
4093
4125
  allowUnpinnedRpcUrl?: boolean | undefined;
4094
4126
  allowUnboundedAmount?: boolean | undefined;
4127
+ allowAuthorityOverlap?: boolean | undefined;
4095
4128
  allowUnpinnedInterpreter?: boolean | undefined;
4096
4129
  baseFee?: number | undefined;
4097
4130
  }, {
@@ -4224,21 +4257,23 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4224
4257
  validUntilLedger?: number | undefined;
4225
4258
  name?: string | undefined;
4226
4259
  } | undefined;
4260
+ outPath?: string | undefined;
4227
4261
  rpcUrl?: string | undefined;
4228
4262
  allowUnpinnedRpcUrl?: boolean | undefined;
4229
4263
  allowUnboundedAmount?: boolean | undefined;
4264
+ allowAuthorityOverlap?: boolean | undefined;
4230
4265
  allowUnpinnedInterpreter?: boolean | undefined;
4231
4266
  baseFee?: number | undefined;
4232
4267
  }>;
4233
4268
  export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>;
4234
4269
  export declare const RevokePolicyInputSchema: z.ZodEffects<z.ZodObject<{
4235
4270
  /** The smart account contract address (C...). */
4236
- smartAccount: z.ZodString;
4271
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
4237
4272
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
4238
4273
  * accepts that signer; this schema does not assert a rule it cannot
4239
4274
  * verify, since the account's source is not in this repo. Proven on
4240
4275
  * testnet: the account's deployer can revoke. */
4241
- sourceAccount: z.ZodString;
4276
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
4242
4277
  /** Target network for the revoke. Same `testnet`-default as install,
4243
4278
  * so pre-mainnet callers keep working without an explicit flag. */
4244
4279
  network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
@@ -14,6 +14,7 @@
14
14
  // This module is the SINGLE source of truth for these shapes. The MCP package
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
16
16
  // them here so it can build the same args envelope the MCP transport builds.
17
+ import { StrKey } from '@stellar/stellar-sdk';
17
18
  import { z } from 'zod';
18
19
  import { isStellarAddress } from "../synth/address.js";
19
20
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
@@ -471,6 +472,20 @@ export const NETWORK_PASSPHRASES = {
471
472
  // `sourceAccount` is the signing wallet (G...).
472
473
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/;
473
474
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/;
475
+ // The regexes above check SHAPE only. A wrong-but-well-formed address - the
476
+ // classic case being one an agent reproduced from memory - passes them and then
477
+ // fails the SDK's StrKey decoder deep inside the build, where the throw is
478
+ // caught by the tool envelope and reported as a bare "invalid checksum" naming
479
+ // no field. A caller holding several addresses then cannot tell which one is
480
+ // wrong. Validating the checksum HERE keeps the field name attached.
481
+ const contractAddress = (field) => z
482
+ .string()
483
+ .regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
484
+ .refine(StrKey.isValidContract, `${field} is not a valid contract address: the checksum does not match, so this address does not exist`);
485
+ const accountAddress = (field) => z
486
+ .string()
487
+ .regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
488
+ .refine(StrKey.isValidEd25519PublicKey, `${field} is not a valid account address: the checksum does not match, so this address does not exist`);
474
489
  // ===== declare_policy =====
475
490
  //
476
491
  // The declarative front-end: the constraint stated outright, with no
@@ -532,14 +547,10 @@ export const InstallPolicyInputSchema = z
532
547
  * result says so rather than reporting "no overlaps found". */
533
548
  existingRules: z.array(ObservedRuleSchema).optional(),
534
549
  /** The smart account contract address (C...) that will receive the rule. */
535
- smartAccount: z
536
- .string()
537
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
550
+ smartAccount: contractAddress('smartAccount'),
538
551
  /** The signer that authorises the install (G... wallet). Used only for
539
552
  * sequence number + auth nonce simulation; never persisted, never signed. */
540
- sourceAccount: z
541
- .string()
542
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
553
+ sourceAccount: accountAddress('sourceAccount'),
543
554
  /** Target network for the install. Selects which interpreter pin and
544
555
  * which RPC URL are valid by default. Defaults to `testnet` so the
545
556
  * pre-mainnet callers keep working: they were always pointing at
@@ -612,6 +623,22 @@ export const InstallPolicyInputSchema = z
612
623
  * knows. Supply it only to re-install over an existing rule, where the
613
624
  * interpreter wants `stored_nonce + 1`. */
614
625
  installNonce: z.number().int().positive().optional(),
626
+ /** Absolute path to write the unsigned envelope to.
627
+ *
628
+ * The envelope runs to several thousand characters, and without this the
629
+ * only route onto disk is the CALLER re-emitting it - through a model, a
630
+ * shell argument, or both. That transport mangles it: observed in practice
631
+ * as a file of the right length whose bytes no longer parse
632
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
633
+ * Writing it here takes the caller out of the transport entirely.
634
+ *
635
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
636
+ outPath: z
637
+ .string()
638
+ .min(1)
639
+ .refine((p) => p.startsWith('/'), 'outPath must be an absolute path')
640
+ .refine((p) => !p.includes('\0'), 'outPath must not contain a null byte')
641
+ .optional(),
615
642
  /** Optional RPC URL override. Defaults to the pinned RPC for the
616
643
  * selected `network` (testnet by default, mainnet when
617
644
  * `network: 'mainnet'`); the override is refused unless
@@ -655,6 +682,18 @@ export const InstallPolicyInputSchema = z
655
682
  * unaffected; only the case the synthesizer explicitly flagged is
656
683
  * refused. */
657
684
  allowUnboundedAmount: z.boolean().optional(),
685
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
686
+ *
687
+ * An OZ account resolves a call against the rule the caller NAMES, so a
688
+ * key on several rules gets the MAXIMUM authority over them, never the
689
+ * intersection. A key that also sits on a rule with no policy is therefore
690
+ * unconstrained: it names that rule and the new predicate never runs.
691
+ *
692
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
693
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
694
+ * this tool cannot decode stays advisory, because "cannot decode" is not
695
+ * "proved unsafe" and refusing it would block installs on a guess. */
696
+ allowAuthorityOverlap: z.boolean().optional(),
658
697
  /** Opt-in to pointing the rule's interpreter policy at any address
659
698
  * other than the pinned interpreter for the selected network.
660
699
  * Default-deny: a caller that controls the interpreter can permit
@@ -673,16 +712,12 @@ export const InstallPolicyInputSchema = z
673
712
  export const RevokePolicyInputSchema = z
674
713
  .object({
675
714
  /** The smart account contract address (C...). */
676
- smartAccount: z
677
- .string()
678
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
715
+ smartAccount: contractAddress('smartAccount'),
679
716
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
680
717
  * accepts that signer; this schema does not assert a rule it cannot
681
718
  * verify, since the account's source is not in this repo. Proven on
682
719
  * testnet: the account's deployer can revoke. */
683
- sourceAccount: z
684
- .string()
685
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
720
+ sourceAccount: accountAddress('sourceAccount'),
686
721
  /** Target network for the revoke. Same `testnet`-default as install,
687
722
  * so pre-mainnet callers keep working without an explicit flag. */
688
723
  network: NetworkSchema.optional(),
@@ -112,6 +112,21 @@ export interface InstallCallDescribes {
112
112
  export interface BuildInstallPolicyResult {
113
113
  /** Unsigned Soroban transaction envelope, base64 XDR. */
114
114
  unsignedXdr: string;
115
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
116
+ * prove it arrived whole.
117
+ *
118
+ * This envelope runs to several thousand characters, and the only route
119
+ * from a tool result onto disk is the caller re-emitting it. A truncated
120
+ * copy is not obviously wrong - it fails later as
121
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
122
+ * transaction rather than a transport problem. Observed in practice: one of
123
+ * two envelopes written in the same session lost its tail and its base64
124
+ * length went from a multiple of four to `len % 4 == 3`.
125
+ *
126
+ * Check both before signing. They are cheap, and they turn a silent,
127
+ * fatal truncation into a retry. */
128
+ unsignedXdrLength: number;
129
+ unsignedXdrSha256: string;
115
130
  /** Smart account contract address (echo). */
116
131
  smartAccount: string;
117
132
  /** Source account (echo) - the address that must sign. */
@@ -168,6 +183,11 @@ export declare function buildRevokePolicyXdr(args: {
168
183
  }): Promise<BuildRevokePolicyResult>;
169
184
  export interface BuildRevokePolicyResult {
170
185
  unsignedXdr: string;
186
+ /** Same integrity pair as the install result, for the same reason: a revoke
187
+ * envelope also has to reach a signer intact, and a truncated copy fails as
188
+ * a malformed transaction rather than as a transport error. */
189
+ unsignedXdrLength: number;
190
+ unsignedXdrSha256: string;
171
191
  smartAccount: string;
172
192
  sourceAccount: string;
173
193
  call: {
@@ -68,6 +68,14 @@ function rpcClientFromServer(server, networkPassphrase) {
68
68
  },
69
69
  };
70
70
  }
71
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
72
+ function xdrIntegrity(unsignedXdr) {
73
+ return {
74
+ unsignedXdr,
75
+ unsignedXdrLength: unsignedXdr.length,
76
+ unsignedXdrSha256: (0, node_crypto_1.createHash)('sha256').update(unsignedXdr, 'utf8').digest('hex'),
77
+ };
78
+ }
71
79
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
72
80
  * The output XDR is signed by the wallet, not by us. */
73
81
  async function buildInstallPolicyXdr(args) {
@@ -96,7 +104,7 @@ async function buildInstallPolicyXdr(args) {
96
104
  // The human approval binds to the exact bytes the wallet will sign.
97
105
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
98
106
  return {
99
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
107
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
100
108
  smartAccount: args.smartAccount,
101
109
  sourceAccount: args.sourceAccount,
102
110
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -125,7 +133,7 @@ async function buildRevokePolicyXdr(args) {
125
133
  // consumer supplies only the ordinary envelope signature.
126
134
  const { finalTx, original, validUntilLedger } = await buildAuthorisedSmartAccountTx(args, 'remove_context_rule', [stellar_sdk_1.xdr.ScVal.scvU32(args.ruleId)], 'revoke_policy');
127
135
  return {
128
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
136
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
129
137
  smartAccount: args.smartAccount,
130
138
  sourceAccount: args.sourceAccount,
131
139
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -38,6 +38,20 @@ 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. That class means the neighbouring rule carries NO
45
+ * policy, so a shared signer names it and the new predicate never runs - a
46
+ * proof, from data already in hand, that the rule constrains nothing.
47
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
48
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
49
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
50
+ * evidence of a bypass and must not refuse on its own.
51
+ *
52
+ * Separated from the tool body so the decision can be tested without a
53
+ * network: the install it guards cannot be built without one. */
54
+ export declare function authorityBypassRefusal(scan: AuthorityOverlap[] | null, allowAuthorityOverlap: boolean | undefined): string | undefined;
41
55
  export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<BuildInstallPolicyResult & {
42
56
  authorityScan: AuthorityOverlap[] | null;
43
57
  }>>;
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.ToolErrorSchema = exports.TESTNET_RPC_URL = exports.SynthesizePolicyInputSchema = exports.RPC_URL_BY_NETWORK = exports.RevokePolicyInputSchema = exports.RecordTransactionInputSchema = exports.RecordedTransactionSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.PINNED_OZ_POLICY_WASM_SHA256 = exports.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.NetworkSchema = exports.MAINNET_RPC_URL = exports.InterpreterOptionsSchema = exports.InstallPolicyInputSchema = exports.GetInterpreterInfoInputSchema = exports.DeclarePolicyInputSchema = exports.ComposeUserResponsesSchema = void 0;
22
22
  exports.runRecordTransaction = runRecordTransaction;
23
23
  exports.runSynthesizePolicy = runSynthesizePolicy;
24
+ exports.authorityBypassRefusal = authorityBypassRefusal;
24
25
  exports.runInstallPolicy = runInstallPolicy;
25
26
  exports.runRevokePolicy = runRevokePolicy;
26
27
  exports.contextTypeForPredicate = contextTypeForPredicate;
@@ -30,6 +31,7 @@ exports.runVerifyPolicy = runVerifyPolicy;
30
31
  exports.runGetInterpreterInfo = runGetInterpreterInfo;
31
32
  exports.caughtError = caughtError;
32
33
  const node_crypto_1 = require("node:crypto");
34
+ const promises_1 = require("node:fs/promises");
33
35
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
34
36
  const adapter_ts_1 = require("../adapters/interpreter/adapter.js");
35
37
  const index_ts_1 = require("../index.js");
@@ -164,6 +166,28 @@ async function runSynthesizePolicy(raw) {
164
166
  return toolFailure('synthesize_policy', e);
165
167
  }
166
168
  }
169
+ /** The refusal message for an install the cross-rule scan proves cannot bind,
170
+ * or `undefined` when the install may proceed.
171
+ *
172
+ * Only `bypass` refuses. That class means the neighbouring rule carries NO
173
+ * policy, so a shared signer names it and the new predicate never runs - a
174
+ * proof, from data already in hand, that the rule constrains nothing.
175
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
176
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
177
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
178
+ * evidence of a bypass and must not refuse on its own.
179
+ *
180
+ * Separated from the tool body so the decision can be tested without a
181
+ * network: the install it guards cannot be built without one. */
182
+ function authorityBypassRefusal(scan, allowAuthorityOverlap) {
183
+ if (scan === null || allowAuthorityOverlap === true)
184
+ return undefined;
185
+ const proven = scan.filter((o) => o.severity === 'bypass');
186
+ if (proven.length === 0)
187
+ return undefined;
188
+ const ids = proven.map((o) => o.ruleId).join(', ');
189
+ return `install_policy: ${proven[0]?.advice ?? ''} This rule would install cleanly and constrain nothing, so it is refused (rule ${ids}); remove the shared signer from that rule, attach a policy to it, or set \`allowAuthorityOverlap: true\` to install anyway.`;
190
+ }
167
191
  async function runInstallPolicy(raw) {
168
192
  const parsed = schemas_ts_1.InstallPolicyInputSchema.safeParse(raw);
169
193
  if (!parsed.success) {
@@ -366,7 +390,52 @@ async function runInstallPolicy(raw) {
366
390
  },
367
391
  existing: observed,
368
392
  });
369
- return { ok: true, data: { ...result, authorityScan } };
393
+ // A `bypass` overlap is not a warning, it is a proof that this rule cannot
394
+ // bind the key it names: the neighbour carries NO policy, so the signer
395
+ // names that rule instead and the predicate never runs. Returning `ok` with
396
+ // the finding buried in `authorityScan` puts the whole protection on the
397
+ // caller reading a field, and the caller here is usually an agent that
398
+ // checks whether the call succeeded. Refuse, and let the caller opt in.
399
+ //
400
+ // Only the provable class. `unknown` - a neighbour whose policy this tool
401
+ // cannot decode - stays advisory: it may well be tighter, and refusing on
402
+ // "cannot decode" would block installs on a guess.
403
+ const bypassRefusal = authorityBypassRefusal(authorityScan, input.allowAuthorityOverlap);
404
+ if (bypassRefusal !== undefined) {
405
+ return {
406
+ ok: false,
407
+ error: {
408
+ code: 'INSTALL_BUILD_FAILED',
409
+ message: bypassRefusal,
410
+ severity: 'error',
411
+ retryable: false,
412
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
413
+ },
414
+ };
415
+ }
416
+ // Write the envelope here when asked, so it never travels through the
417
+ // caller. `writtenTo` is what the caller should hand to a signer.
418
+ let writtenTo;
419
+ if (input.outPath !== undefined) {
420
+ // Write beside the target and rename, which is atomic within a
421
+ // filesystem. A plain write truncates first, so anything watching the
422
+ // directory - a signer picking up envelopes is the obvious case - can
423
+ // read a half-written file and report a malformed TRANSACTION. With a
424
+ // rename the path either does not exist or holds the whole envelope.
425
+ const staging = `${input.outPath}.partial`;
426
+ await (0, promises_1.writeFile)(staging, result.unsignedXdr, 'utf8');
427
+ const readBack = await (0, promises_1.readFile)(staging, 'utf8');
428
+ if (readBack !== result.unsignedXdr) {
429
+ await (0, promises_1.rm)(staging, { force: true });
430
+ throw new Error(`outPath: wrote ${result.unsignedXdr.length} characters to ${input.outPath} but read back ${readBack.length}; the file was not persisted intact`);
431
+ }
432
+ await (0, promises_1.rename)(staging, input.outPath);
433
+ writtenTo = input.outPath;
434
+ }
435
+ return {
436
+ ok: true,
437
+ data: { ...result, authorityScan, ...(writtenTo !== undefined ? { writtenTo } : {}) },
438
+ };
370
439
  }
371
440
  catch (e) {
372
441
  return toolFailure('install_policy', e);
@@ -2849,10 +2849,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2849
2849
  predicate?: unknown;
2850
2850
  }>, "many">>;
2851
2851
  /** The smart account contract address (C...) that will receive the rule. */
2852
- smartAccount: z.ZodString;
2852
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
2853
2853
  /** The signer that authorises the install (G... wallet). Used only for
2854
2854
  * sequence number + auth nonce simulation; never persisted, never signed. */
2855
- sourceAccount: z.ZodString;
2855
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
2856
2856
  /** Target network for the install. Selects which interpreter pin and
2857
2857
  * which RPC URL are valid by default. Defaults to `testnet` so the
2858
2858
  * pre-mainnet callers keep working: they were always pointing at
@@ -3366,6 +3366,17 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3366
3366
  * knows. Supply it only to re-install over an existing rule, where the
3367
3367
  * interpreter wants `stored_nonce + 1`. */
3368
3368
  installNonce: z.ZodOptional<z.ZodNumber>;
3369
+ /** Absolute path to write the unsigned envelope to.
3370
+ *
3371
+ * The envelope runs to several thousand characters, and without this the
3372
+ * only route onto disk is the CALLER re-emitting it - through a model, a
3373
+ * shell argument, or both. That transport mangles it: observed in practice
3374
+ * as a file of the right length whose bytes no longer parse
3375
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
3376
+ * Writing it here takes the caller out of the transport entirely.
3377
+ *
3378
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
3379
+ outPath: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
3369
3380
  /** Optional RPC URL override. Defaults to the pinned RPC for the
3370
3381
  * selected `network` (testnet by default, mainnet when
3371
3382
  * `network: 'mainnet'`); the override is refused unless
@@ -3411,6 +3422,18 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3411
3422
  * unaffected; only the case the synthesizer explicitly flagged is
3412
3423
  * refused. */
3413
3424
  allowUnboundedAmount: z.ZodOptional<z.ZodBoolean>;
3425
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
3426
+ *
3427
+ * An OZ account resolves a call against the rule the caller NAMES, so a
3428
+ * key on several rules gets the MAXIMUM authority over them, never the
3429
+ * intersection. A key that also sits on a rule with no policy is therefore
3430
+ * unconstrained: it names that rule and the new predicate never runs.
3431
+ *
3432
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
3433
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
3434
+ * this tool cannot decode stays advisory, because "cannot decode" is not
3435
+ * "proved unsafe" and refusing it would block installs on a guess. */
3436
+ allowAuthorityOverlap: z.ZodOptional<z.ZodBoolean>;
3414
3437
  /** Opt-in to pointing the rule's interpreter policy at any address
3415
3438
  * other than the pinned interpreter for the selected network.
3416
3439
  * Default-deny: a caller that controls the interpreter can permit
@@ -3549,9 +3572,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3549
3572
  validUntilLedger?: number | undefined;
3550
3573
  name?: string | undefined;
3551
3574
  } | undefined;
3575
+ outPath?: string | undefined;
3552
3576
  rpcUrl?: string | undefined;
3553
3577
  allowUnpinnedRpcUrl?: boolean | undefined;
3554
3578
  allowUnboundedAmount?: boolean | undefined;
3579
+ allowAuthorityOverlap?: boolean | undefined;
3555
3580
  allowUnpinnedInterpreter?: boolean | undefined;
3556
3581
  baseFee?: number | undefined;
3557
3582
  }, {
@@ -3684,9 +3709,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3684
3709
  validUntilLedger?: number | undefined;
3685
3710
  name?: string | undefined;
3686
3711
  } | undefined;
3712
+ outPath?: string | undefined;
3687
3713
  rpcUrl?: string | undefined;
3688
3714
  allowUnpinnedRpcUrl?: boolean | undefined;
3689
3715
  allowUnboundedAmount?: boolean | undefined;
3716
+ allowAuthorityOverlap?: boolean | undefined;
3690
3717
  allowUnpinnedInterpreter?: boolean | undefined;
3691
3718
  baseFee?: number | undefined;
3692
3719
  }>, {
@@ -3819,9 +3846,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3819
3846
  validUntilLedger?: number | undefined;
3820
3847
  name?: string | undefined;
3821
3848
  } | undefined;
3849
+ outPath?: string | undefined;
3822
3850
  rpcUrl?: string | undefined;
3823
3851
  allowUnpinnedRpcUrl?: boolean | undefined;
3824
3852
  allowUnboundedAmount?: boolean | undefined;
3853
+ allowAuthorityOverlap?: boolean | undefined;
3825
3854
  allowUnpinnedInterpreter?: boolean | undefined;
3826
3855
  baseFee?: number | undefined;
3827
3856
  }, {
@@ -3954,9 +3983,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3954
3983
  validUntilLedger?: number | undefined;
3955
3984
  name?: string | undefined;
3956
3985
  } | undefined;
3986
+ outPath?: string | undefined;
3957
3987
  rpcUrl?: string | undefined;
3958
3988
  allowUnpinnedRpcUrl?: boolean | undefined;
3959
3989
  allowUnboundedAmount?: boolean | undefined;
3990
+ allowAuthorityOverlap?: boolean | undefined;
3960
3991
  allowUnpinnedInterpreter?: boolean | undefined;
3961
3992
  baseFee?: number | undefined;
3962
3993
  }>, {
@@ -4089,9 +4120,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4089
4120
  validUntilLedger?: number | undefined;
4090
4121
  name?: string | undefined;
4091
4122
  } | undefined;
4123
+ outPath?: string | undefined;
4092
4124
  rpcUrl?: string | undefined;
4093
4125
  allowUnpinnedRpcUrl?: boolean | undefined;
4094
4126
  allowUnboundedAmount?: boolean | undefined;
4127
+ allowAuthorityOverlap?: boolean | undefined;
4095
4128
  allowUnpinnedInterpreter?: boolean | undefined;
4096
4129
  baseFee?: number | undefined;
4097
4130
  }, {
@@ -4224,21 +4257,23 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4224
4257
  validUntilLedger?: number | undefined;
4225
4258
  name?: string | undefined;
4226
4259
  } | undefined;
4260
+ outPath?: string | undefined;
4227
4261
  rpcUrl?: string | undefined;
4228
4262
  allowUnpinnedRpcUrl?: boolean | undefined;
4229
4263
  allowUnboundedAmount?: boolean | undefined;
4264
+ allowAuthorityOverlap?: boolean | undefined;
4230
4265
  allowUnpinnedInterpreter?: boolean | undefined;
4231
4266
  baseFee?: number | undefined;
4232
4267
  }>;
4233
4268
  export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>;
4234
4269
  export declare const RevokePolicyInputSchema: z.ZodEffects<z.ZodObject<{
4235
4270
  /** The smart account contract address (C...). */
4236
- smartAccount: z.ZodString;
4271
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
4237
4272
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
4238
4273
  * accepts that signer; this schema does not assert a rule it cannot
4239
4274
  * verify, since the account's source is not in this repo. Proven on
4240
4275
  * testnet: the account's deployer can revoke. */
4241
- sourceAccount: z.ZodString;
4276
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
4242
4277
  /** Target network for the revoke. Same `testnet`-default as install,
4243
4278
  * so pre-mainnet callers keep working without an explicit flag. */
4244
4279
  network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
@@ -17,6 +17,7 @@
17
17
  // them here so it can build the same args envelope the MCP transport builds.
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.ToolErrorSchema = exports.GetInterpreterInfoInputSchema = exports.RevokePolicyInputSchema = exports.InstallPolicyInputSchema = exports.DeclarePolicyInputSchema = exports.NETWORK_PASSPHRASES = exports.PINNED_OZ_POLICY_WASM_SHA256 = exports.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK = exports.PINNED_OZ_STELLAR_CONTRACTS_TAG = exports.RPC_URL_BY_NETWORK = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.MAINNET_RPC_URL = exports.TESTNET_RPC_URL = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.ObservedRuleSchema = exports.VerifyPolicyInputSchema = exports.SimulatePolicyInputSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.SynthesizePolicyInputSchema = exports.InterpreterOptionsSchema = exports.RecordTransactionInputSchema = exports.ComposeUserResponsesSchema = exports.RecordedTransactionSchema = exports.ParseConfidenceSchema = exports.OnChainEventSchema = exports.TokenMovementSchema = exports.ContractInvocationSchema = exports.ScValSchema = exports.NetworkSchema = void 0;
20
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
20
21
  const zod_1 = require("zod");
21
22
  const address_ts_1 = require("../synth/address.js");
22
23
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
@@ -474,6 +475,20 @@ exports.NETWORK_PASSPHRASES = {
474
475
  // `sourceAccount` is the signing wallet (G...).
475
476
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/;
476
477
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/;
478
+ // The regexes above check SHAPE only. A wrong-but-well-formed address - the
479
+ // classic case being one an agent reproduced from memory - passes them and then
480
+ // fails the SDK's StrKey decoder deep inside the build, where the throw is
481
+ // caught by the tool envelope and reported as a bare "invalid checksum" naming
482
+ // no field. A caller holding several addresses then cannot tell which one is
483
+ // wrong. Validating the checksum HERE keeps the field name attached.
484
+ const contractAddress = (field) => zod_1.z
485
+ .string()
486
+ .regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
487
+ .refine(stellar_sdk_1.StrKey.isValidContract, `${field} is not a valid contract address: the checksum does not match, so this address does not exist`);
488
+ const accountAddress = (field) => zod_1.z
489
+ .string()
490
+ .regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
491
+ .refine(stellar_sdk_1.StrKey.isValidEd25519PublicKey, `${field} is not a valid account address: the checksum does not match, so this address does not exist`);
477
492
  // ===== declare_policy =====
478
493
  //
479
494
  // The declarative front-end: the constraint stated outright, with no
@@ -535,14 +550,10 @@ exports.InstallPolicyInputSchema = zod_1.z
535
550
  * result says so rather than reporting "no overlaps found". */
536
551
  existingRules: zod_1.z.array(exports.ObservedRuleSchema).optional(),
537
552
  /** The smart account contract address (C...) that will receive the rule. */
538
- smartAccount: zod_1.z
539
- .string()
540
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
553
+ smartAccount: contractAddress('smartAccount'),
541
554
  /** The signer that authorises the install (G... wallet). Used only for
542
555
  * sequence number + auth nonce simulation; never persisted, never signed. */
543
- sourceAccount: zod_1.z
544
- .string()
545
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
556
+ sourceAccount: accountAddress('sourceAccount'),
546
557
  /** Target network for the install. Selects which interpreter pin and
547
558
  * which RPC URL are valid by default. Defaults to `testnet` so the
548
559
  * pre-mainnet callers keep working: they were always pointing at
@@ -615,6 +626,22 @@ exports.InstallPolicyInputSchema = zod_1.z
615
626
  * knows. Supply it only to re-install over an existing rule, where the
616
627
  * interpreter wants `stored_nonce + 1`. */
617
628
  installNonce: zod_1.z.number().int().positive().optional(),
629
+ /** Absolute path to write the unsigned envelope to.
630
+ *
631
+ * The envelope runs to several thousand characters, and without this the
632
+ * only route onto disk is the CALLER re-emitting it - through a model, a
633
+ * shell argument, or both. That transport mangles it: observed in practice
634
+ * as a file of the right length whose bytes no longer parse
635
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
636
+ * Writing it here takes the caller out of the transport entirely.
637
+ *
638
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
639
+ outPath: zod_1.z
640
+ .string()
641
+ .min(1)
642
+ .refine((p) => p.startsWith('/'), 'outPath must be an absolute path')
643
+ .refine((p) => !p.includes('\0'), 'outPath must not contain a null byte')
644
+ .optional(),
618
645
  /** Optional RPC URL override. Defaults to the pinned RPC for the
619
646
  * selected `network` (testnet by default, mainnet when
620
647
  * `network: 'mainnet'`); the override is refused unless
@@ -658,6 +685,18 @@ exports.InstallPolicyInputSchema = zod_1.z
658
685
  * unaffected; only the case the synthesizer explicitly flagged is
659
686
  * refused. */
660
687
  allowUnboundedAmount: zod_1.z.boolean().optional(),
688
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
689
+ *
690
+ * An OZ account resolves a call against the rule the caller NAMES, so a
691
+ * key on several rules gets the MAXIMUM authority over them, never the
692
+ * intersection. A key that also sits on a rule with no policy is therefore
693
+ * unconstrained: it names that rule and the new predicate never runs.
694
+ *
695
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
696
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
697
+ * this tool cannot decode stays advisory, because "cannot decode" is not
698
+ * "proved unsafe" and refusing it would block installs on a guess. */
699
+ allowAuthorityOverlap: zod_1.z.boolean().optional(),
661
700
  /** Opt-in to pointing the rule's interpreter policy at any address
662
701
  * other than the pinned interpreter for the selected network.
663
702
  * Default-deny: a caller that controls the interpreter can permit
@@ -676,16 +715,12 @@ exports.InstallPolicyInputSchema = zod_1.z
676
715
  exports.RevokePolicyInputSchema = zod_1.z
677
716
  .object({
678
717
  /** The smart account contract address (C...). */
679
- smartAccount: zod_1.z
680
- .string()
681
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
718
+ smartAccount: contractAddress('smartAccount'),
682
719
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
683
720
  * accepts that signer; this schema does not assert a rule it cannot
684
721
  * verify, since the account's source is not in this repo. Proven on
685
722
  * testnet: the account's deployer can revoke. */
686
- sourceAccount: zod_1.z
687
- .string()
688
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
723
+ sourceAccount: accountAddress('sourceAccount'),
689
724
  /** Target network for the revoke. Same `testnet`-default as install,
690
725
  * so pre-mainnet callers keep working without an explicit flag. */
691
726
  network: exports.NetworkSchema.optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -182,12 +182,40 @@ export interface InstallCallDescribes {
182
182
  installNonce: number
183
183
  }
184
184
 
185
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
186
+ function xdrIntegrity(unsignedXdr: string): {
187
+ unsignedXdr: string
188
+ unsignedXdrLength: number
189
+ unsignedXdrSha256: string
190
+ } {
191
+ return {
192
+ unsignedXdr,
193
+ unsignedXdrLength: unsignedXdr.length,
194
+ unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
195
+ }
196
+ }
197
+
185
198
  /** Output of the install-policy build. The unsigned XDR is the wallet's
186
199
  * input; the captured auth nonce + invocation root make the response
187
200
  * self-describing for callers that want to inspect what they signed. */
188
201
  export interface BuildInstallPolicyResult {
189
202
  /** Unsigned Soroban transaction envelope, base64 XDR. */
190
203
  unsignedXdr: string
204
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
205
+ * prove it arrived whole.
206
+ *
207
+ * This envelope runs to several thousand characters, and the only route
208
+ * from a tool result onto disk is the caller re-emitting it. A truncated
209
+ * copy is not obviously wrong - it fails later as
210
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
211
+ * transaction rather than a transport problem. Observed in practice: one of
212
+ * two envelopes written in the same session lost its tail and its base64
213
+ * length went from a multiple of four to `len % 4 == 3`.
214
+ *
215
+ * Check both before signing. They are cheap, and they turn a silent,
216
+ * fatal truncation into a retry. */
217
+ unsignedXdrLength: number
218
+ unsignedXdrSha256: string
191
219
  /** Smart account contract address (echo). */
192
220
  smartAccount: string
193
221
  /** Source account (echo) - the address that must sign. */
@@ -258,7 +286,7 @@ export async function buildInstallPolicyXdr(
258
286
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
259
287
 
260
288
  return {
261
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
289
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
262
290
  smartAccount: args.smartAccount,
263
291
  sourceAccount: args.sourceAccount,
264
292
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -302,7 +330,7 @@ export async function buildRevokePolicyXdr(args: {
302
330
  )
303
331
 
304
332
  return {
305
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
333
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
306
334
  smartAccount: args.smartAccount,
307
335
  sourceAccount: args.sourceAccount,
308
336
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -314,6 +342,11 @@ export async function buildRevokePolicyXdr(args: {
314
342
 
315
343
  export interface BuildRevokePolicyResult {
316
344
  unsignedXdr: string
345
+ /** Same integrity pair as the install result, for the same reason: a revoke
346
+ * envelope also has to reach a signer intact, and a truncated copy fails as
347
+ * a malformed transaction rather than as a transport error. */
348
+ unsignedXdrLength: number
349
+ unsignedXdrSha256: string
317
350
  smartAccount: string
318
351
  sourceAccount: string
319
352
  call: { contract: string; fn: 'remove_context_rule'; ruleId: number }
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,30 @@ 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. That class means the neighbouring rule carries NO
244
+ * policy, so a shared signer names it and the new predicate never runs - a
245
+ * proof, from data already in hand, that the rule constrains nothing.
246
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
247
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
248
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
249
+ * evidence of a bypass and must not refuse on its own.
250
+ *
251
+ * Separated from the tool body so the decision can be tested without a
252
+ * network: the install it guards cannot be built without one. */
253
+ export function authorityBypassRefusal(
254
+ scan: AuthorityOverlap[] | null,
255
+ allowAuthorityOverlap: boolean | undefined
256
+ ): string | undefined {
257
+ if (scan === null || allowAuthorityOverlap === true) return undefined
258
+ const proven = scan.filter((o) => o.severity === 'bypass')
259
+ if (proven.length === 0) return undefined
260
+ const ids = proven.map((o) => o.ruleId).join(', ')
261
+ return `install_policy: ${proven[0]?.advice ?? ''} This rule would install cleanly and constrain nothing, so it is refused (rule ${ids}); remove the shared signer from that rule, attach a policy to it, or set \`allowAuthorityOverlap: true\` to install anyway.`
262
+ }
263
+
239
264
  export async function runInstallPolicy(
240
265
  raw: unknown
241
266
  ): Promise<ToolResponse<BuildInstallPolicyResult & { authorityScan: AuthorityOverlap[] | null }>> {
@@ -453,7 +478,54 @@ export async function runInstallPolicy(
453
478
  },
454
479
  existing: observed,
455
480
  })
456
- return { ok: true, data: { ...result, authorityScan } }
481
+ // A `bypass` overlap is not a warning, it is a proof that this rule cannot
482
+ // bind the key it names: the neighbour carries NO policy, so the signer
483
+ // names that rule instead and the predicate never runs. Returning `ok` with
484
+ // the finding buried in `authorityScan` puts the whole protection on the
485
+ // caller reading a field, and the caller here is usually an agent that
486
+ // checks whether the call succeeded. Refuse, and let the caller opt in.
487
+ //
488
+ // Only the provable class. `unknown` - a neighbour whose policy this tool
489
+ // cannot decode - stays advisory: it may well be tighter, and refusing on
490
+ // "cannot decode" would block installs on a guess.
491
+ const bypassRefusal = authorityBypassRefusal(authorityScan, input.allowAuthorityOverlap)
492
+ if (bypassRefusal !== undefined) {
493
+ return {
494
+ ok: false,
495
+ error: {
496
+ code: 'INSTALL_BUILD_FAILED',
497
+ message: bypassRefusal,
498
+ severity: 'error',
499
+ retryable: false,
500
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
501
+ },
502
+ }
503
+ }
504
+ // Write the envelope here when asked, so it never travels through the
505
+ // caller. `writtenTo` is what the caller should hand to a signer.
506
+ let writtenTo: string | undefined
507
+ if (input.outPath !== undefined) {
508
+ // Write beside the target and rename, which is atomic within a
509
+ // filesystem. A plain write truncates first, so anything watching the
510
+ // directory - a signer picking up envelopes is the obvious case - can
511
+ // read a half-written file and report a malformed TRANSACTION. With a
512
+ // rename the path either does not exist or holds the whole envelope.
513
+ const staging = `${input.outPath}.partial`
514
+ await writeFile(staging, result.unsignedXdr, 'utf8')
515
+ const readBack = await readFile(staging, 'utf8')
516
+ if (readBack !== result.unsignedXdr) {
517
+ await rm(staging, { force: true })
518
+ throw new Error(
519
+ `outPath: wrote ${result.unsignedXdr.length} characters to ${input.outPath} but read back ${readBack.length}; the file was not persisted intact`
520
+ )
521
+ }
522
+ await rename(staging, input.outPath)
523
+ writtenTo = input.outPath
524
+ }
525
+ return {
526
+ ok: true,
527
+ data: { ...result, authorityScan, ...(writtenTo !== undefined ? { writtenTo } : {}) },
528
+ }
457
529
  } catch (e) {
458
530
  return toolFailure('install_policy', e)
459
531
  }
@@ -15,6 +15,7 @@
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
16
16
  // them here so it can build the same args envelope the MCP transport builds.
17
17
 
18
+ import { StrKey } from '@stellar/stellar-sdk'
18
19
  import { z } from 'zod'
19
20
  import { isStellarAddress } from '../synth/address.ts'
20
21
 
@@ -550,6 +551,29 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
550
551
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
551
552
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
552
553
 
554
+ // The regexes above check SHAPE only. A wrong-but-well-formed address - the
555
+ // classic case being one an agent reproduced from memory - passes them and then
556
+ // fails the SDK's StrKey decoder deep inside the build, where the throw is
557
+ // caught by the tool envelope and reported as a bare "invalid checksum" naming
558
+ // no field. A caller holding several addresses then cannot tell which one is
559
+ // wrong. Validating the checksum HERE keeps the field name attached.
560
+ const contractAddress = (field: string) =>
561
+ z
562
+ .string()
563
+ .regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
564
+ .refine(
565
+ StrKey.isValidContract,
566
+ `${field} is not a valid contract address: the checksum does not match, so this address does not exist`
567
+ )
568
+ const accountAddress = (field: string) =>
569
+ z
570
+ .string()
571
+ .regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
572
+ .refine(
573
+ StrKey.isValidEd25519PublicKey,
574
+ `${field} is not a valid account address: the checksum does not match, so this address does not exist`
575
+ )
576
+
553
577
  // ===== declare_policy =====
554
578
  //
555
579
  // The declarative front-end: the constraint stated outright, with no
@@ -613,14 +637,10 @@ export const InstallPolicyInputSchema = z
613
637
  * result says so rather than reporting "no overlaps found". */
614
638
  existingRules: z.array(ObservedRuleSchema).optional(),
615
639
  /** The smart account contract address (C...) that will receive the rule. */
616
- smartAccount: z
617
- .string()
618
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
640
+ smartAccount: contractAddress('smartAccount'),
619
641
  /** The signer that authorises the install (G... wallet). Used only for
620
642
  * sequence number + auth nonce simulation; never persisted, never signed. */
621
- sourceAccount: z
622
- .string()
623
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
643
+ sourceAccount: accountAddress('sourceAccount'),
624
644
  /** Target network for the install. Selects which interpreter pin and
625
645
  * which RPC URL are valid by default. Defaults to `testnet` so the
626
646
  * pre-mainnet callers keep working: they were always pointing at
@@ -693,6 +713,22 @@ export const InstallPolicyInputSchema = z
693
713
  * knows. Supply it only to re-install over an existing rule, where the
694
714
  * interpreter wants `stored_nonce + 1`. */
695
715
  installNonce: z.number().int().positive().optional(),
716
+ /** Absolute path to write the unsigned envelope to.
717
+ *
718
+ * The envelope runs to several thousand characters, and without this the
719
+ * only route onto disk is the CALLER re-emitting it - through a model, a
720
+ * shell argument, or both. That transport mangles it: observed in practice
721
+ * as a file of the right length whose bytes no longer parse
722
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
723
+ * Writing it here takes the caller out of the transport entirely.
724
+ *
725
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
726
+ outPath: z
727
+ .string()
728
+ .min(1)
729
+ .refine((p) => p.startsWith('/'), 'outPath must be an absolute path')
730
+ .refine((p) => !p.includes('\0'), 'outPath must not contain a null byte')
731
+ .optional(),
696
732
  /** Optional RPC URL override. Defaults to the pinned RPC for the
697
733
  * selected `network` (testnet by default, mainnet when
698
734
  * `network: 'mainnet'`); the override is refused unless
@@ -736,6 +772,18 @@ export const InstallPolicyInputSchema = z
736
772
  * unaffected; only the case the synthesizer explicitly flagged is
737
773
  * refused. */
738
774
  allowUnboundedAmount: z.boolean().optional(),
775
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
776
+ *
777
+ * An OZ account resolves a call against the rule the caller NAMES, so a
778
+ * key on several rules gets the MAXIMUM authority over them, never the
779
+ * intersection. A key that also sits on a rule with no policy is therefore
780
+ * unconstrained: it names that rule and the new predicate never runs.
781
+ *
782
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
783
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
784
+ * this tool cannot decode stays advisory, because "cannot decode" is not
785
+ * "proved unsafe" and refusing it would block installs on a guess. */
786
+ allowAuthorityOverlap: z.boolean().optional(),
739
787
  /** Opt-in to pointing the rule's interpreter policy at any address
740
788
  * other than the pinned interpreter for the selected network.
741
789
  * Default-deny: a caller that controls the interpreter can permit
@@ -760,16 +808,12 @@ export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
760
808
  export const RevokePolicyInputSchema = z
761
809
  .object({
762
810
  /** The smart account contract address (C...). */
763
- smartAccount: z
764
- .string()
765
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
811
+ smartAccount: contractAddress('smartAccount'),
766
812
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
767
813
  * accepts that signer; this schema does not assert a rule it cannot
768
814
  * verify, since the account's source is not in this repo. Proven on
769
815
  * testnet: the account's deployer can revoke. */
770
- sourceAccount: z
771
- .string()
772
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
816
+ sourceAccount: accountAddress('sourceAccount'),
773
817
  /** Target network for the revoke. Same `testnet`-default as install,
774
818
  * so pre-mainnet callers keep working without an explicit flag. */
775
819
  network: NetworkSchema.optional(),