@crediolabs/policy-synth 1.1.1 → 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.
@@ -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);
@@ -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,9 +4257,11 @@ 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
  }>;
@@ -623,6 +623,22 @@ export const InstallPolicyInputSchema = z
623
623
  * knows. Supply it only to re-install over an existing rule, where the
624
624
  * interpreter wants `stored_nonce + 1`. */
625
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(),
626
642
  /** Optional RPC URL override. Defaults to the pinned RPC for the
627
643
  * selected `network` (testnet by default, mainnet when
628
644
  * `network: 'mainnet'`); the override is refused unless
@@ -666,6 +682,18 @@ export const InstallPolicyInputSchema = z
666
682
  * unaffected; only the case the synthesizer explicitly flagged is
667
683
  * refused. */
668
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(),
669
697
  /** Opt-in to pointing the rule's interpreter policy at any address
670
698
  * other than the pinned interpreter for the selected network.
671
699
  * Default-deny: a caller that controls the interpreter can permit
@@ -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);
@@ -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,9 +4257,11 @@ 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
  }>;
@@ -626,6 +626,22 @@ exports.InstallPolicyInputSchema = zod_1.z
626
626
  * knows. Supply it only to re-install over an existing rule, where the
627
627
  * interpreter wants `stored_nonce + 1`. */
628
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(),
629
645
  /** Optional RPC URL override. Defaults to the pinned RPC for the
630
646
  * selected `network` (testnet by default, mainnet when
631
647
  * `network: 'mainnet'`); the override is refused unless
@@ -669,6 +685,18 @@ exports.InstallPolicyInputSchema = zod_1.z
669
685
  * unaffected; only the case the synthesizer explicitly flagged is
670
686
  * refused. */
671
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(),
672
700
  /** Opt-in to pointing the rule's interpreter policy at any address
673
701
  * other than the pinned interpreter for the selected network.
674
702
  * Default-deny: a caller that controls the interpreter can permit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "1.1.1",
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",
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
  }
@@ -713,6 +713,22 @@ export const InstallPolicyInputSchema = z
713
713
  * knows. Supply it only to re-install over an existing rule, where the
714
714
  * interpreter wants `stored_nonce + 1`. */
715
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(),
716
732
  /** Optional RPC URL override. Defaults to the pinned RPC for the
717
733
  * selected `network` (testnet by default, mainnet when
718
734
  * `network: 'mainnet'`); the override is refused unless
@@ -756,6 +772,18 @@ export const InstallPolicyInputSchema = z
756
772
  * unaffected; only the case the synthesizer explicitly flagged is
757
773
  * refused. */
758
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(),
759
787
  /** Opt-in to pointing the rule's interpreter policy at any address
760
788
  * other than the pinned interpreter for the selected network.
761
789
  * Default-deny: a caller that controls the interpreter can permit