@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.
- package/dist/install/build-install-policy.d.ts +20 -0
- package/dist/install/build-install-policy.js +10 -2
- package/dist/run/index.d.ts +14 -0
- package/dist/run/index.js +69 -1
- package/dist/run/schemas.d.ts +39 -4
- package/dist/run/schemas.js +47 -12
- package/dist-cjs/install/build-install-policy.d.ts +20 -0
- package/dist-cjs/install/build-install-policy.js +10 -2
- package/dist-cjs/run/index.d.ts +14 -0
- package/dist-cjs/run/index.js +70 -1
- package/dist-cjs/run/schemas.d.ts +39 -4
- package/dist-cjs/run/schemas.js +47 -12
- package/package.json +1 -1
- package/src/install/build-install-policy.ts +35 -2
- package/src/run/index.ts +73 -1
- package/src/run/schemas.ts +56 -12
|
@@ -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
|
-
|
|
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
|
-
|
|
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 },
|
package/dist/run/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/run/schemas.d.ts
CHANGED
|
@@ -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"]>>;
|
package/dist/run/schemas.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
package/dist-cjs/run/index.d.ts
CHANGED
|
@@ -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-cjs/run/index.js
CHANGED
|
@@ -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
|
-
|
|
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"]>>;
|
package/dist-cjs/run/schemas.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/run/schemas.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(),
|