@crediolabs/policy-synth 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/install/authority-overlap.d.ts +41 -1
- package/dist/install/authority-overlap.js +58 -9
- package/dist/install/read-account-rules.d.ts +23 -1
- package/dist/install/read-account-rules.js +56 -1
- package/dist/run/index.d.ts +17 -0
- package/dist/run/index.js +94 -1
- package/dist/run/schemas.d.ts +101 -0
- package/dist/run/schemas.js +37 -0
- package/dist-cjs/install/authority-overlap.d.ts +41 -1
- package/dist-cjs/install/authority-overlap.js +58 -9
- package/dist-cjs/install/read-account-rules.d.ts +23 -1
- package/dist-cjs/install/read-account-rules.js +58 -0
- package/dist-cjs/run/index.d.ts +17 -0
- package/dist-cjs/run/index.js +95 -1
- package/dist-cjs/run/schemas.d.ts +101 -0
- package/dist-cjs/run/schemas.js +37 -0
- package/package.json +1 -1
- package/src/install/authority-overlap.ts +112 -11
- package/src/install/read-account-rules.ts +74 -1
- package/src/run/index.ts +98 -1
- package/src/run/schemas.ts +37 -0
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,36 @@ 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, and it covers two proofs. Either the neighbouring
|
|
173
|
+
* rule carries NO policy, so a shared signer names it and the new predicate
|
|
174
|
+
* never runs; or the install carries a rolling total and a fully recognised
|
|
175
|
+
* neighbour serves the same calls without one, so the total is not a bound on
|
|
176
|
+
* the key. Both are proofs from data already in hand.
|
|
177
|
+
*
|
|
178
|
+
* `unknown` (a neighbour policed by a contract this tool cannot decode) stays
|
|
179
|
+
* advisory: it may well be tighter, and refusing on "cannot decode" would
|
|
180
|
+
* block installs on a guess. A `null` scan is NOT CHECKED, which is not
|
|
181
|
+
* evidence of a bypass and must not refuse on its own.
|
|
182
|
+
*
|
|
183
|
+
* Separated from the tool body so the decision can be tested without a
|
|
184
|
+
* network: the install it guards cannot be built without one. */
|
|
185
|
+
function authorityBypassRefusal(scan, allowAuthorityOverlap) {
|
|
186
|
+
if (scan === null || allowAuthorityOverlap === true)
|
|
187
|
+
return undefined;
|
|
188
|
+
const proven = scan.filter((o) => o.severity === 'bypass');
|
|
189
|
+
if (proven.length === 0)
|
|
190
|
+
return undefined;
|
|
191
|
+
const ids = proven.map((o) => o.ruleId).join(', ');
|
|
192
|
+
// A cap bypass leaves the predicate working, so "constrains nothing" would
|
|
193
|
+
// overstate it and send the caller looking for the wrong defect.
|
|
194
|
+
const consequence = proven.every((o) => o.capBypass === true)
|
|
195
|
+
? 'This rule would install cleanly and its rolling total would not hold'
|
|
196
|
+
: 'This rule would install cleanly and constrain nothing';
|
|
197
|
+
return `install_policy: ${proven[0]?.advice ?? ''} ${consequence}, so it is refused (rule ${ids}); set \`allowAuthorityOverlap: true\` to install anyway.`;
|
|
198
|
+
}
|
|
167
199
|
async function runInstallPolicy(raw) {
|
|
168
200
|
const parsed = schemas_ts_1.InstallPolicyInputSchema.safeParse(raw);
|
|
169
201
|
if (!parsed.success) {
|
|
@@ -363,10 +395,71 @@ async function runInstallPolicy(raw) {
|
|
|
363
395
|
contextType: rule.contextRuleType,
|
|
364
396
|
signers: rule.signers,
|
|
365
397
|
predicate: (0, decode_ts_1.decodePredicate)(encodedPredicate),
|
|
398
|
+
...(input.spendingLimit !== undefined
|
|
399
|
+
? {
|
|
400
|
+
spendCap: {
|
|
401
|
+
amount: input.spendingLimit.amount,
|
|
402
|
+
periodLedgers: input.spendingLimit.periodLedgers,
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
: {}),
|
|
366
406
|
},
|
|
367
407
|
existing: observed,
|
|
408
|
+
// Both addresses are pinned, so a neighbour's policies can be
|
|
409
|
+
// named rather than merely counted - which is what lets the scan
|
|
410
|
+
// say a rule has no spend cap instead of that it has something
|
|
411
|
+
// unreadable.
|
|
412
|
+
knownPolicies: {
|
|
413
|
+
interpreter: expectedInterpreter,
|
|
414
|
+
spendingLimit: schemas_ts_1.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
415
|
+
},
|
|
368
416
|
});
|
|
369
|
-
|
|
417
|
+
// A `bypass` overlap is not a warning, it is a proof that this rule cannot
|
|
418
|
+
// bind the key it names: the neighbour carries NO policy, so the signer
|
|
419
|
+
// names that rule instead and the predicate never runs. Returning `ok` with
|
|
420
|
+
// the finding buried in `authorityScan` puts the whole protection on the
|
|
421
|
+
// caller reading a field, and the caller here is usually an agent that
|
|
422
|
+
// checks whether the call succeeded. Refuse, and let the caller opt in.
|
|
423
|
+
//
|
|
424
|
+
// Only the provable class. `unknown` - a neighbour whose policy this tool
|
|
425
|
+
// cannot decode - stays advisory: it may well be tighter, and refusing on
|
|
426
|
+
// "cannot decode" would block installs on a guess.
|
|
427
|
+
const bypassRefusal = authorityBypassRefusal(authorityScan, input.allowAuthorityOverlap);
|
|
428
|
+
if (bypassRefusal !== undefined) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: {
|
|
432
|
+
code: 'INSTALL_BUILD_FAILED',
|
|
433
|
+
message: bypassRefusal,
|
|
434
|
+
severity: 'error',
|
|
435
|
+
retryable: false,
|
|
436
|
+
remediation: { toolCall: { name: 'install_policy', args: {} } },
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
// Write the envelope here when asked, so it never travels through the
|
|
441
|
+
// caller. `writtenTo` is what the caller should hand to a signer.
|
|
442
|
+
let writtenTo;
|
|
443
|
+
if (input.outPath !== undefined) {
|
|
444
|
+
// Write beside the target and rename, which is atomic within a
|
|
445
|
+
// filesystem. A plain write truncates first, so anything watching the
|
|
446
|
+
// directory - a signer picking up envelopes is the obvious case - can
|
|
447
|
+
// read a half-written file and report a malformed TRANSACTION. With a
|
|
448
|
+
// rename the path either does not exist or holds the whole envelope.
|
|
449
|
+
const staging = `${input.outPath}.partial`;
|
|
450
|
+
await (0, promises_1.writeFile)(staging, result.unsignedXdr, 'utf8');
|
|
451
|
+
const readBack = await (0, promises_1.readFile)(staging, 'utf8');
|
|
452
|
+
if (readBack !== result.unsignedXdr) {
|
|
453
|
+
await (0, promises_1.rm)(staging, { force: true });
|
|
454
|
+
throw new Error(`outPath: wrote ${result.unsignedXdr.length} characters to ${input.outPath} but read back ${readBack.length}; the file was not persisted intact`);
|
|
455
|
+
}
|
|
456
|
+
await (0, promises_1.rename)(staging, input.outPath);
|
|
457
|
+
writtenTo = input.outPath;
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
data: { ...result, authorityScan, ...(writtenTo !== undefined ? { writtenTo } : {}) },
|
|
462
|
+
};
|
|
370
463
|
}
|
|
371
464
|
catch (e) {
|
|
372
465
|
return toolFailure('install_policy', e);
|
|
@@ -744,6 +837,7 @@ async function resolveExistingRules(input, network, interpreterAddress) {
|
|
|
744
837
|
reader: (0, read_account_rules_ts_1.accountRuleReaderFromServer)(server, schemas_ts_1.NETWORK_PASSPHRASES[network]),
|
|
745
838
|
smartAccount: input.smartAccount,
|
|
746
839
|
interpreterAddress,
|
|
840
|
+
spendingLimitAddress: schemas_ts_1.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
747
841
|
});
|
|
748
842
|
if (collected.incomplete)
|
|
749
843
|
return null;
|
|
@@ -2554,6 +2554,19 @@ export declare const ObservedRuleSchema: z.ZodObject<{
|
|
|
2554
2554
|
}>]>, "many">;
|
|
2555
2555
|
policyAddresses: z.ZodArray<z.ZodString, "many">;
|
|
2556
2556
|
predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
|
|
2557
|
+
/** Parameters of an OZ spend cap already on this rule. Reporting only:
|
|
2558
|
+
* whether a neighbour is capped is read from `policyAddresses`, so omitting
|
|
2559
|
+
* this never turns a capped rule into an uncapped one. */
|
|
2560
|
+
spendCap: z.ZodOptional<z.ZodObject<{
|
|
2561
|
+
amount: z.ZodString;
|
|
2562
|
+
periodLedgers: z.ZodNumber;
|
|
2563
|
+
}, "strip", z.ZodTypeAny, {
|
|
2564
|
+
amount: string;
|
|
2565
|
+
periodLedgers: number;
|
|
2566
|
+
}, {
|
|
2567
|
+
amount: string;
|
|
2568
|
+
periodLedgers: number;
|
|
2569
|
+
}>>;
|
|
2557
2570
|
}, "strip", z.ZodTypeAny, {
|
|
2558
2571
|
signers: ({
|
|
2559
2572
|
address: string;
|
|
@@ -2574,6 +2587,10 @@ export declare const ObservedRuleSchema: z.ZodObject<{
|
|
|
2574
2587
|
kind: "create_contract";
|
|
2575
2588
|
wasmHash: string;
|
|
2576
2589
|
};
|
|
2590
|
+
spendCap?: {
|
|
2591
|
+
amount: string;
|
|
2592
|
+
periodLedgers: number;
|
|
2593
|
+
} | undefined;
|
|
2577
2594
|
predicate?: unknown;
|
|
2578
2595
|
}, {
|
|
2579
2596
|
signers: ({
|
|
@@ -2595,6 +2612,10 @@ export declare const ObservedRuleSchema: z.ZodObject<{
|
|
|
2595
2612
|
kind: "create_contract";
|
|
2596
2613
|
wasmHash: string;
|
|
2597
2614
|
};
|
|
2615
|
+
spendCap?: {
|
|
2616
|
+
amount: string;
|
|
2617
|
+
periodLedgers: number;
|
|
2618
|
+
} | undefined;
|
|
2598
2619
|
predicate?: unknown;
|
|
2599
2620
|
}>;
|
|
2600
2621
|
/** Pinned interpreter address (testnet).
|
|
@@ -2805,6 +2826,19 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
2805
2826
|
}>]>, "many">;
|
|
2806
2827
|
policyAddresses: z.ZodArray<z.ZodString, "many">;
|
|
2807
2828
|
predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
|
|
2829
|
+
/** Parameters of an OZ spend cap already on this rule. Reporting only:
|
|
2830
|
+
* whether a neighbour is capped is read from `policyAddresses`, so omitting
|
|
2831
|
+
* this never turns a capped rule into an uncapped one. */
|
|
2832
|
+
spendCap: z.ZodOptional<z.ZodObject<{
|
|
2833
|
+
amount: z.ZodString;
|
|
2834
|
+
periodLedgers: z.ZodNumber;
|
|
2835
|
+
}, "strip", z.ZodTypeAny, {
|
|
2836
|
+
amount: string;
|
|
2837
|
+
periodLedgers: number;
|
|
2838
|
+
}, {
|
|
2839
|
+
amount: string;
|
|
2840
|
+
periodLedgers: number;
|
|
2841
|
+
}>>;
|
|
2808
2842
|
}, "strip", z.ZodTypeAny, {
|
|
2809
2843
|
signers: ({
|
|
2810
2844
|
address: string;
|
|
@@ -2825,6 +2859,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
2825
2859
|
kind: "create_contract";
|
|
2826
2860
|
wasmHash: string;
|
|
2827
2861
|
};
|
|
2862
|
+
spendCap?: {
|
|
2863
|
+
amount: string;
|
|
2864
|
+
periodLedgers: number;
|
|
2865
|
+
} | undefined;
|
|
2828
2866
|
predicate?: unknown;
|
|
2829
2867
|
}, {
|
|
2830
2868
|
signers: ({
|
|
@@ -2846,6 +2884,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
2846
2884
|
kind: "create_contract";
|
|
2847
2885
|
wasmHash: string;
|
|
2848
2886
|
};
|
|
2887
|
+
spendCap?: {
|
|
2888
|
+
amount: string;
|
|
2889
|
+
periodLedgers: number;
|
|
2890
|
+
} | undefined;
|
|
2849
2891
|
predicate?: unknown;
|
|
2850
2892
|
}>, "many">>;
|
|
2851
2893
|
/** The smart account contract address (C...) that will receive the rule. */
|
|
@@ -3366,6 +3408,17 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3366
3408
|
* knows. Supply it only to re-install over an existing rule, where the
|
|
3367
3409
|
* interpreter wants `stored_nonce + 1`. */
|
|
3368
3410
|
installNonce: z.ZodOptional<z.ZodNumber>;
|
|
3411
|
+
/** Absolute path to write the unsigned envelope to.
|
|
3412
|
+
*
|
|
3413
|
+
* The envelope runs to several thousand characters, and without this the
|
|
3414
|
+
* only route onto disk is the CALLER re-emitting it - through a model, a
|
|
3415
|
+
* shell argument, or both. That transport mangles it: observed in practice
|
|
3416
|
+
* as a file of the right length whose bytes no longer parse
|
|
3417
|
+
* ("xdr padding contains non-zero bytes"), and as silent truncation.
|
|
3418
|
+
* Writing it here takes the caller out of the transport entirely.
|
|
3419
|
+
*
|
|
3420
|
+
* Opt-in: omitted, nothing is written and behaviour is unchanged. */
|
|
3421
|
+
outPath: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
|
|
3369
3422
|
/** Optional RPC URL override. Defaults to the pinned RPC for the
|
|
3370
3423
|
* selected `network` (testnet by default, mainnet when
|
|
3371
3424
|
* `network: 'mainnet'`); the override is refused unless
|
|
@@ -3411,6 +3464,18 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3411
3464
|
* unaffected; only the case the synthesizer explicitly flagged is
|
|
3412
3465
|
* refused. */
|
|
3413
3466
|
allowUnboundedAmount: z.ZodOptional<z.ZodBoolean>;
|
|
3467
|
+
/** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
|
|
3468
|
+
*
|
|
3469
|
+
* An OZ account resolves a call against the rule the caller NAMES, so a
|
|
3470
|
+
* key on several rules gets the MAXIMUM authority over them, never the
|
|
3471
|
+
* intersection. A key that also sits on a rule with no policy is therefore
|
|
3472
|
+
* unconstrained: it names that rule and the new predicate never runs.
|
|
3473
|
+
*
|
|
3474
|
+
* Default-deny, and only for the case the scan can PROVE - an unpoliced
|
|
3475
|
+
* neighbour sharing signers and selectors. A neighbour carrying a policy
|
|
3476
|
+
* this tool cannot decode stays advisory, because "cannot decode" is not
|
|
3477
|
+
* "proved unsafe" and refusing it would block installs on a guess. */
|
|
3478
|
+
allowAuthorityOverlap: z.ZodOptional<z.ZodBoolean>;
|
|
3414
3479
|
/** Opt-in to pointing the rule's interpreter policy at any address
|
|
3415
3480
|
* other than the pinned interpreter for the selected network.
|
|
3416
3481
|
* Default-deny: a caller that controls the interpreter can permit
|
|
@@ -3448,6 +3513,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3448
3513
|
kind: "create_contract";
|
|
3449
3514
|
wasmHash: string;
|
|
3450
3515
|
};
|
|
3516
|
+
spendCap?: {
|
|
3517
|
+
amount: string;
|
|
3518
|
+
periodLedgers: number;
|
|
3519
|
+
} | undefined;
|
|
3451
3520
|
predicate?: unknown;
|
|
3452
3521
|
}[] | undefined;
|
|
3453
3522
|
rule?: z.objectOutputType<{
|
|
@@ -3549,9 +3618,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3549
3618
|
validUntilLedger?: number | undefined;
|
|
3550
3619
|
name?: string | undefined;
|
|
3551
3620
|
} | undefined;
|
|
3621
|
+
outPath?: string | undefined;
|
|
3552
3622
|
rpcUrl?: string | undefined;
|
|
3553
3623
|
allowUnpinnedRpcUrl?: boolean | undefined;
|
|
3554
3624
|
allowUnboundedAmount?: boolean | undefined;
|
|
3625
|
+
allowAuthorityOverlap?: boolean | undefined;
|
|
3555
3626
|
allowUnpinnedInterpreter?: boolean | undefined;
|
|
3556
3627
|
baseFee?: number | undefined;
|
|
3557
3628
|
}, {
|
|
@@ -3583,6 +3654,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3583
3654
|
kind: "create_contract";
|
|
3584
3655
|
wasmHash: string;
|
|
3585
3656
|
};
|
|
3657
|
+
spendCap?: {
|
|
3658
|
+
amount: string;
|
|
3659
|
+
periodLedgers: number;
|
|
3660
|
+
} | undefined;
|
|
3586
3661
|
predicate?: unknown;
|
|
3587
3662
|
}[] | undefined;
|
|
3588
3663
|
rule?: z.objectInputType<{
|
|
@@ -3684,9 +3759,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3684
3759
|
validUntilLedger?: number | undefined;
|
|
3685
3760
|
name?: string | undefined;
|
|
3686
3761
|
} | undefined;
|
|
3762
|
+
outPath?: string | undefined;
|
|
3687
3763
|
rpcUrl?: string | undefined;
|
|
3688
3764
|
allowUnpinnedRpcUrl?: boolean | undefined;
|
|
3689
3765
|
allowUnboundedAmount?: boolean | undefined;
|
|
3766
|
+
allowAuthorityOverlap?: boolean | undefined;
|
|
3690
3767
|
allowUnpinnedInterpreter?: boolean | undefined;
|
|
3691
3768
|
baseFee?: number | undefined;
|
|
3692
3769
|
}>, {
|
|
@@ -3718,6 +3795,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3718
3795
|
kind: "create_contract";
|
|
3719
3796
|
wasmHash: string;
|
|
3720
3797
|
};
|
|
3798
|
+
spendCap?: {
|
|
3799
|
+
amount: string;
|
|
3800
|
+
periodLedgers: number;
|
|
3801
|
+
} | undefined;
|
|
3721
3802
|
predicate?: unknown;
|
|
3722
3803
|
}[] | undefined;
|
|
3723
3804
|
rule?: z.objectOutputType<{
|
|
@@ -3819,9 +3900,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3819
3900
|
validUntilLedger?: number | undefined;
|
|
3820
3901
|
name?: string | undefined;
|
|
3821
3902
|
} | undefined;
|
|
3903
|
+
outPath?: string | undefined;
|
|
3822
3904
|
rpcUrl?: string | undefined;
|
|
3823
3905
|
allowUnpinnedRpcUrl?: boolean | undefined;
|
|
3824
3906
|
allowUnboundedAmount?: boolean | undefined;
|
|
3907
|
+
allowAuthorityOverlap?: boolean | undefined;
|
|
3825
3908
|
allowUnpinnedInterpreter?: boolean | undefined;
|
|
3826
3909
|
baseFee?: number | undefined;
|
|
3827
3910
|
}, {
|
|
@@ -3853,6 +3936,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3853
3936
|
kind: "create_contract";
|
|
3854
3937
|
wasmHash: string;
|
|
3855
3938
|
};
|
|
3939
|
+
spendCap?: {
|
|
3940
|
+
amount: string;
|
|
3941
|
+
periodLedgers: number;
|
|
3942
|
+
} | undefined;
|
|
3856
3943
|
predicate?: unknown;
|
|
3857
3944
|
}[] | undefined;
|
|
3858
3945
|
rule?: z.objectInputType<{
|
|
@@ -3954,9 +4041,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3954
4041
|
validUntilLedger?: number | undefined;
|
|
3955
4042
|
name?: string | undefined;
|
|
3956
4043
|
} | undefined;
|
|
4044
|
+
outPath?: string | undefined;
|
|
3957
4045
|
rpcUrl?: string | undefined;
|
|
3958
4046
|
allowUnpinnedRpcUrl?: boolean | undefined;
|
|
3959
4047
|
allowUnboundedAmount?: boolean | undefined;
|
|
4048
|
+
allowAuthorityOverlap?: boolean | undefined;
|
|
3960
4049
|
allowUnpinnedInterpreter?: boolean | undefined;
|
|
3961
4050
|
baseFee?: number | undefined;
|
|
3962
4051
|
}>, {
|
|
@@ -3988,6 +4077,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3988
4077
|
kind: "create_contract";
|
|
3989
4078
|
wasmHash: string;
|
|
3990
4079
|
};
|
|
4080
|
+
spendCap?: {
|
|
4081
|
+
amount: string;
|
|
4082
|
+
periodLedgers: number;
|
|
4083
|
+
} | undefined;
|
|
3991
4084
|
predicate?: unknown;
|
|
3992
4085
|
}[] | undefined;
|
|
3993
4086
|
rule?: z.objectOutputType<{
|
|
@@ -4089,9 +4182,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
4089
4182
|
validUntilLedger?: number | undefined;
|
|
4090
4183
|
name?: string | undefined;
|
|
4091
4184
|
} | undefined;
|
|
4185
|
+
outPath?: string | undefined;
|
|
4092
4186
|
rpcUrl?: string | undefined;
|
|
4093
4187
|
allowUnpinnedRpcUrl?: boolean | undefined;
|
|
4094
4188
|
allowUnboundedAmount?: boolean | undefined;
|
|
4189
|
+
allowAuthorityOverlap?: boolean | undefined;
|
|
4095
4190
|
allowUnpinnedInterpreter?: boolean | undefined;
|
|
4096
4191
|
baseFee?: number | undefined;
|
|
4097
4192
|
}, {
|
|
@@ -4123,6 +4218,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
4123
4218
|
kind: "create_contract";
|
|
4124
4219
|
wasmHash: string;
|
|
4125
4220
|
};
|
|
4221
|
+
spendCap?: {
|
|
4222
|
+
amount: string;
|
|
4223
|
+
periodLedgers: number;
|
|
4224
|
+
} | undefined;
|
|
4126
4225
|
predicate?: unknown;
|
|
4127
4226
|
}[] | undefined;
|
|
4128
4227
|
rule?: z.objectInputType<{
|
|
@@ -4224,9 +4323,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
4224
4323
|
validUntilLedger?: number | undefined;
|
|
4225
4324
|
name?: string | undefined;
|
|
4226
4325
|
} | undefined;
|
|
4326
|
+
outPath?: string | undefined;
|
|
4227
4327
|
rpcUrl?: string | undefined;
|
|
4228
4328
|
allowUnpinnedRpcUrl?: boolean | undefined;
|
|
4229
4329
|
allowUnboundedAmount?: boolean | undefined;
|
|
4330
|
+
allowAuthorityOverlap?: boolean | undefined;
|
|
4230
4331
|
allowUnpinnedInterpreter?: boolean | undefined;
|
|
4231
4332
|
baseFee?: number | undefined;
|
|
4232
4333
|
}>;
|
package/dist-cjs/run/schemas.js
CHANGED
|
@@ -341,6 +341,15 @@ exports.ObservedRuleSchema = zod_1.z.object({
|
|
|
341
341
|
signers: zod_1.z.array(SignerDraftSchema),
|
|
342
342
|
policyAddresses: zod_1.z.array(zod_1.z.string()),
|
|
343
343
|
predicate: exports.PredicateNodeSchema.optional(),
|
|
344
|
+
/** Parameters of an OZ spend cap already on this rule. Reporting only:
|
|
345
|
+
* whether a neighbour is capped is read from `policyAddresses`, so omitting
|
|
346
|
+
* this never turns a capped rule into an uncapped one. */
|
|
347
|
+
spendCap: zod_1.z
|
|
348
|
+
.object({
|
|
349
|
+
amount: zod_1.z.string().regex(/^[0-9]+$/),
|
|
350
|
+
periodLedgers: zod_1.z.number().int().positive().max(U32_MAX),
|
|
351
|
+
})
|
|
352
|
+
.optional(),
|
|
344
353
|
});
|
|
345
354
|
const ContextRuleDraftSchema = zod_1.z
|
|
346
355
|
.object({
|
|
@@ -626,6 +635,22 @@ exports.InstallPolicyInputSchema = zod_1.z
|
|
|
626
635
|
* knows. Supply it only to re-install over an existing rule, where the
|
|
627
636
|
* interpreter wants `stored_nonce + 1`. */
|
|
628
637
|
installNonce: zod_1.z.number().int().positive().optional(),
|
|
638
|
+
/** Absolute path to write the unsigned envelope to.
|
|
639
|
+
*
|
|
640
|
+
* The envelope runs to several thousand characters, and without this the
|
|
641
|
+
* only route onto disk is the CALLER re-emitting it - through a model, a
|
|
642
|
+
* shell argument, or both. That transport mangles it: observed in practice
|
|
643
|
+
* as a file of the right length whose bytes no longer parse
|
|
644
|
+
* ("xdr padding contains non-zero bytes"), and as silent truncation.
|
|
645
|
+
* Writing it here takes the caller out of the transport entirely.
|
|
646
|
+
*
|
|
647
|
+
* Opt-in: omitted, nothing is written and behaviour is unchanged. */
|
|
648
|
+
outPath: zod_1.z
|
|
649
|
+
.string()
|
|
650
|
+
.min(1)
|
|
651
|
+
.refine((p) => p.startsWith('/'), 'outPath must be an absolute path')
|
|
652
|
+
.refine((p) => !p.includes('\0'), 'outPath must not contain a null byte')
|
|
653
|
+
.optional(),
|
|
629
654
|
/** Optional RPC URL override. Defaults to the pinned RPC for the
|
|
630
655
|
* selected `network` (testnet by default, mainnet when
|
|
631
656
|
* `network: 'mainnet'`); the override is refused unless
|
|
@@ -669,6 +694,18 @@ exports.InstallPolicyInputSchema = zod_1.z
|
|
|
669
694
|
* unaffected; only the case the synthesizer explicitly flagged is
|
|
670
695
|
* refused. */
|
|
671
696
|
allowUnboundedAmount: zod_1.z.boolean().optional(),
|
|
697
|
+
/** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
|
|
698
|
+
*
|
|
699
|
+
* An OZ account resolves a call against the rule the caller NAMES, so a
|
|
700
|
+
* key on several rules gets the MAXIMUM authority over them, never the
|
|
701
|
+
* intersection. A key that also sits on a rule with no policy is therefore
|
|
702
|
+
* unconstrained: it names that rule and the new predicate never runs.
|
|
703
|
+
*
|
|
704
|
+
* Default-deny, and only for the case the scan can PROVE - an unpoliced
|
|
705
|
+
* neighbour sharing signers and selectors. A neighbour carrying a policy
|
|
706
|
+
* this tool cannot decode stays advisory, because "cannot decode" is not
|
|
707
|
+
* "proved unsafe" and refusing it would block installs on a guess. */
|
|
708
|
+
allowAuthorityOverlap: zod_1.z.boolean().optional(),
|
|
672
709
|
/** Opt-in to pointing the rule's interpreter policy at any address
|
|
673
710
|
* other than the pinned interpreter for the selected network.
|
|
674
711
|
* 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.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -50,6 +50,26 @@ export type ContextType =
|
|
|
50
50
|
* signers may do without constraint. */
|
|
51
51
|
export type RuleClass = 'interpreter' | 'foreign' | 'unpoliced'
|
|
52
52
|
|
|
53
|
+
/** An OZ `spending_limit`'s parameters. `amount` is in the token's smallest
|
|
54
|
+
* unit; the period is a LEDGER count, not seconds. */
|
|
55
|
+
export interface SpendCap {
|
|
56
|
+
amount: string
|
|
57
|
+
periodLedgers: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The policy contracts this tool can recognise by address. Supplying them lets
|
|
61
|
+
* the scan reason about a neighbour's SPEND CAP instead of treating any
|
|
62
|
+
* non-interpreter policy as opaque.
|
|
63
|
+
*
|
|
64
|
+
* Recognition is what licenses the strong conclusion. "This rule has no spend
|
|
65
|
+
* cap" is only sound when every policy on it is accounted for; a single
|
|
66
|
+
* unrecognised address could be somebody else's cap, so such a rule stays
|
|
67
|
+
* advisory. */
|
|
68
|
+
export interface KnownPolicies {
|
|
69
|
+
interpreter: string
|
|
70
|
+
spendingLimit: string
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
export interface ObservedRule {
|
|
54
74
|
id: number
|
|
55
75
|
contextType: ContextType
|
|
@@ -59,6 +79,11 @@ export interface ObservedRule {
|
|
|
59
79
|
/** Decoded predicate. Present only when the rule is policed by OUR
|
|
60
80
|
* interpreter and the stored document was readable. */
|
|
61
81
|
predicate?: PredicateNode
|
|
82
|
+
/** The attached spend cap's parameters, when the reader could read them from
|
|
83
|
+
* the policy's own storage. Attachment is decided from `policyAddresses`, so
|
|
84
|
+
* this being absent does NOT mean the rule is uncapped - only that the
|
|
85
|
+
* numbers are unknown. */
|
|
86
|
+
spendCap?: SpendCap
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
export interface IntendedInstall {
|
|
@@ -69,10 +94,16 @@ export interface IntendedInstall {
|
|
|
69
94
|
contextType: ContextType
|
|
70
95
|
signers: SignerDraft[]
|
|
71
96
|
predicate: PredicateNode
|
|
97
|
+
/** The rolling cap being installed alongside the predicate, when one is.
|
|
98
|
+
* Its presence is what makes a neighbour's LACK of a cap a finding: without
|
|
99
|
+
* it there is no total for a neighbour to route around. */
|
|
100
|
+
spendCap?: SpendCap
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
export type OverlapSeverity =
|
|
75
|
-
/** A neighbouring rule imposes no constraint at all on the shared calls
|
|
104
|
+
/** A neighbouring rule imposes no constraint at all on the shared calls, or
|
|
105
|
+
* imposes no ROLLING TOTAL on calls the new rule caps. Either way the new
|
|
106
|
+
* rule's bound does not hold for a signer who can name this one. */
|
|
76
107
|
| 'bypass'
|
|
77
108
|
/** A neighbouring policy exists but what it permits cannot be read. */
|
|
78
109
|
| 'unknown'
|
|
@@ -89,6 +120,14 @@ export interface AuthorityOverlap {
|
|
|
89
120
|
sharedSigners: SignerDraft[]
|
|
90
121
|
/** The selectors both rules can serve. Non-empty by construction. */
|
|
91
122
|
sharedSelectors: Selector[]
|
|
123
|
+
/** This neighbour's own rolling cap, when it has one this tool could read.
|
|
124
|
+
* A spend cap is keyed by (account, RULE id), so two capped rules do not
|
|
125
|
+
* share a budget - a signer on both may spend the SUM. */
|
|
126
|
+
spendCap?: SpendCap
|
|
127
|
+
/** True when the new rule installs a rolling total and this neighbour serves
|
|
128
|
+
* some of the same calls WITHOUT one, which voids the total rather than
|
|
129
|
+
* merely widening it. Only set when every policy here was recognised. */
|
|
130
|
+
capBypass?: true
|
|
92
131
|
advice: string
|
|
93
132
|
}
|
|
94
133
|
|
|
@@ -265,14 +304,45 @@ function classifyRule(rule: ObservedRule): RuleClass {
|
|
|
265
304
|
return rule.predicate ? 'interpreter' : 'foreign'
|
|
266
305
|
}
|
|
267
306
|
|
|
268
|
-
|
|
307
|
+
/** Is the OZ spend cap attached to this rule? Decided by ADDRESS, so it holds
|
|
308
|
+
* whether or not the cap's parameters could be read. */
|
|
309
|
+
function hasSpendCap(rule: ObservedRule, known: KnownPolicies): boolean {
|
|
310
|
+
return rule.policyAddresses.includes(known.spendingLimit)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Every policy on the rule is one this tool knows the semantics of, so
|
|
314
|
+
* "there is no spend cap here" is an observation rather than an assumption. */
|
|
315
|
+
function allPoliciesRecognised(rule: ObservedRule, known: KnownPolicies): boolean {
|
|
316
|
+
return rule.policyAddresses.every(
|
|
317
|
+
(addr) => addr === known.interpreter || addr === known.spendingLimit
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function capBypassAdvice(ruleId: number): string {
|
|
322
|
+
return `the rolling total you are installing will not bind: rule ${ruleId} serves some of the same calls for a shared signer and carries NO spend cap, so that signer spends through it without one. A cap is stored per RULE, never per key. Remove the shared signer from rule ${ruleId}, put an equivalent cap on it, or narrow it so it no longer serves these calls.`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** How a signer's spend adds up across two capped rules. Same period or not,
|
|
326
|
+
* the budgets are separate; saying so with the numbers beats saying it in the
|
|
327
|
+
* abstract, which is what the caller has to reason about. */
|
|
328
|
+
function combinedCapNote(mine: SpendCap, theirs: SpendCap): string {
|
|
329
|
+
if (mine.periodLedgers === theirs.periodLedgers) {
|
|
330
|
+
const total = (BigInt(mine.amount) + BigInt(theirs.amount)).toString()
|
|
331
|
+
return ` Its cap is ${theirs.amount} over the same ${theirs.periodLedgers}-ledger period as yours, and the two budgets are separate, so a shared signer may spend ${total} in total.`
|
|
332
|
+
}
|
|
333
|
+
return ` Its cap is ${theirs.amount} over ${theirs.periodLedgers} ledgers against your ${mine.amount} over ${mine.periodLedgers}; the periods differ, so the two budgets neither share nor cancel and a shared signer draws on both.`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function adviceFor(cls: RuleClass, ruleId: number, theirCap?: SpendCap, myCap?: SpendCap): string {
|
|
337
|
+
const capNote =
|
|
338
|
+
theirCap !== undefined && myCap !== undefined ? combinedCapNote(myCap, theirCap) : ''
|
|
269
339
|
switch (cls) {
|
|
270
340
|
case 'unpoliced':
|
|
271
341
|
return `rule ${ruleId} has no policy attached, so a shared signer may make these calls with no constraint at all - the predicate you are installing will never run for them. Remove the shared signer from rule ${ruleId}, or attach a policy to it.`
|
|
272
342
|
case 'foreign':
|
|
273
|
-
return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule
|
|
343
|
+
return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule.${capNote}`
|
|
274
344
|
case 'interpreter':
|
|
275
|
-
return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other
|
|
345
|
+
return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other.${capNote}`
|
|
276
346
|
}
|
|
277
347
|
}
|
|
278
348
|
|
|
@@ -287,6 +357,9 @@ function adviceFor(cls: RuleClass, ruleId: number): string {
|
|
|
287
357
|
export function findAuthorityOverlaps(args: {
|
|
288
358
|
intended: IntendedInstall
|
|
289
359
|
existing: ObservedRule[]
|
|
360
|
+
/** Omit to skip spend-cap reasoning entirely: every neighbour is then judged
|
|
361
|
+
* exactly as before, on its policies' presence rather than their meaning. */
|
|
362
|
+
knownPolicies?: KnownPolicies
|
|
290
363
|
}): AuthorityOverlap[] {
|
|
291
364
|
const intendedSelectors = intersectSelectors(
|
|
292
365
|
selectorsForContextType(args.intended.contextType),
|
|
@@ -304,18 +377,46 @@ export function findAuthorityOverlaps(args: {
|
|
|
304
377
|
if (sharedSelectors.length === 0) continue
|
|
305
378
|
|
|
306
379
|
const ruleClass = classifyRule(rule)
|
|
380
|
+
// A rolling total is keyed by (account, rule id), so it constrains THIS
|
|
381
|
+
// rule and nothing else. If the new rule carries one and a neighbour serves
|
|
382
|
+
// the same calls without one, the signer names the neighbour and spends
|
|
383
|
+
// without limit - the total was never a bound on the key. Proven on testnet
|
|
384
|
+
// in `docs/audit/evidence/oz-two-rule-blend-cap.log`, where an uncapped
|
|
385
|
+
// sibling rule passed the very amount the capped rule refused.
|
|
386
|
+
//
|
|
387
|
+
// Sound only when every policy on the neighbour was recognised. One
|
|
388
|
+
// unrecognised address could be another spend cap, and refusing an install
|
|
389
|
+
// over a policy we cannot read would be a guess, not a proof.
|
|
390
|
+
// An unpoliced neighbour is excluded deliberately. It has no policies, so
|
|
391
|
+
// it passes the "everything recognised, no cap" test vacuously - but the
|
|
392
|
+
// finding there is not that a total leaks, it is that NOTHING constrains
|
|
393
|
+
// those calls. Reporting the narrower cause would send the reader looking
|
|
394
|
+
// for a spend cap when the rule needs a policy at all.
|
|
395
|
+
const capBypass =
|
|
396
|
+
ruleClass !== 'unpoliced' &&
|
|
397
|
+
args.knownPolicies !== undefined &&
|
|
398
|
+
args.intended.spendCap !== undefined &&
|
|
399
|
+
allPoliciesRecognised(rule, args.knownPolicies) &&
|
|
400
|
+
!hasSpendCap(rule, args.knownPolicies)
|
|
401
|
+
|
|
402
|
+
const severity: OverlapSeverity =
|
|
403
|
+
ruleClass === 'unpoliced' || capBypass
|
|
404
|
+
? 'bypass'
|
|
405
|
+
: ruleClass === 'foreign'
|
|
406
|
+
? 'unknown'
|
|
407
|
+
: 'not-restricting'
|
|
408
|
+
|
|
307
409
|
out.push({
|
|
308
410
|
ruleId: rule.id,
|
|
309
411
|
ruleClass,
|
|
310
|
-
severity
|
|
311
|
-
ruleClass === 'unpoliced'
|
|
312
|
-
? 'bypass'
|
|
313
|
-
: ruleClass === 'foreign'
|
|
314
|
-
? 'unknown'
|
|
315
|
-
: 'not-restricting',
|
|
412
|
+
severity,
|
|
316
413
|
sharedSigners: shared,
|
|
317
414
|
sharedSelectors,
|
|
318
|
-
|
|
415
|
+
...(rule.spendCap !== undefined ? { spendCap: rule.spendCap } : {}),
|
|
416
|
+
...(capBypass ? { capBypass: true as const } : {}),
|
|
417
|
+
advice: capBypass
|
|
418
|
+
? capBypassAdvice(rule.id)
|
|
419
|
+
: adviceFor(ruleClass, rule.id, rule.spendCap, args.intended.spendCap),
|
|
319
420
|
})
|
|
320
421
|
}
|
|
321
422
|
|