@crediolabs/policy-builder-cli 0.1.18 → 0.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/README.md +5 -3
- package/dist/bin/policy-builder.js +9 -33
- package/dist/src/commands/synthesize.js +17 -76
- package/dist-cjs/src/commands/synthesize.js +17 -76
- package/package.json +5 -5
- package/src/commands/synthesize.ts +16 -96
package/README.md
CHANGED
|
@@ -58,9 +58,11 @@ small: the two commands that fit a pipe.
|
|
|
58
58
|
|
|
59
59
|
## Security model
|
|
60
60
|
|
|
61
|
-
The CLI holds no key material and signs nothing.
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
The CLI holds no key material and signs nothing. See the
|
|
62
|
+
[architecture document](https://github.com/untangledfinance/oz-policy-builder/blob/main/docs/architecture.md)
|
|
63
|
+
for what the on-chain interpreter does and does not enforce, and the
|
|
64
|
+
[repository README](https://github.com/untangledfinance/oz-policy-builder#readme) for
|
|
65
|
+
the contracts' audit status.
|
|
64
66
|
|
|
65
67
|
## License
|
|
66
68
|
|
|
@@ -4,16 +4,11 @@
|
|
|
4
4
|
// Usage:
|
|
5
5
|
// policy-builder record --network <mainnet|testnet> --hash <tx>
|
|
6
6
|
// [--xdr <b64>] [--json] [--quiet] [--out <path>]
|
|
7
|
-
// policy-builder synthesize --mandate <path.json>
|
|
8
|
-
// [--oz-config <path.json>] [--confidence <0..1>]
|
|
9
|
-
// [--explain] [--json] [--quiet] [--out <path>]
|
|
10
7
|
// policy-builder synthesize --recorded-tx <path.json> --network <mainnet|testnet>
|
|
11
8
|
// [--responses <path.json>]
|
|
12
|
-
// [--
|
|
9
|
+
// [--confidence <0..1>]
|
|
13
10
|
// [--smart-account <C...>] [--install-nonce <n>]
|
|
14
|
-
// [--
|
|
15
|
-
// [--window-seconds <n>] [--valid-until <ledger>]
|
|
16
|
-
// [--limit-amount <i128str>] [--invocation-limit <n>]
|
|
11
|
+
// [--valid-until <ledger>] [--limit-amount <i128str>]
|
|
17
12
|
// [--recipient <C...|G...>]...
|
|
18
13
|
// [--explain] [--json] [--quiet] [--out <path>]
|
|
19
14
|
//
|
|
@@ -69,52 +64,33 @@ function printHelp() {
|
|
|
69
64
|
Usage:
|
|
70
65
|
policy-builder record --network <mainnet|testnet> --hash <tx> | --xdr <b64>
|
|
71
66
|
[--json] [--quiet] [--out <path>]
|
|
72
|
-
policy-builder synthesize --mandate <path.json>
|
|
73
|
-
[--oz-config <path.json>] [--confidence <0..1>]
|
|
74
|
-
[--json] [--quiet] [--out <path>]
|
|
75
67
|
policy-builder synthesize --recorded-tx <path.json> --network <mainnet|testnet>
|
|
76
68
|
[--responses <path.json>]
|
|
77
|
-
[--
|
|
69
|
+
[--confidence <0..1>]
|
|
78
70
|
[--smart-account <C...>] [--install-nonce <n>]
|
|
79
|
-
[--
|
|
80
|
-
[--window-seconds <n>] [--valid-until <ledger>]
|
|
81
|
-
[--limit-amount <i128str>] [--invocation-limit <n>]
|
|
71
|
+
[--valid-until <ledger>] [--limit-amount <i128str>]
|
|
82
72
|
[--recipient <C...|G...>]...
|
|
83
|
-
[--json] [--quiet] [--out <path>]
|
|
73
|
+
[--explain] [--json] [--quiet] [--out <path>]
|
|
84
74
|
|
|
85
75
|
Flags:
|
|
86
76
|
--json emit machine-readable JSON on stdout
|
|
87
77
|
--quiet suppress progress / non-error output
|
|
88
78
|
--out write artefact to file (JSON)
|
|
89
|
-
--oz-config path to a JSON OzAdapterConfig (custom OZ instance
|
|
90
|
-
addresses for the network); applies to both the
|
|
91
|
-
--mandate and --recorded-tx synthesize paths
|
|
92
79
|
--confidence recorder confidence GATE threshold (0..1, inclusive);
|
|
93
80
|
rejects when parseConfidence.overall < threshold; does
|
|
94
81
|
not change the recording's parseConfidence.thresholdUsed
|
|
95
82
|
in the output
|
|
96
|
-
--smart-account C... account
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
83
|
+
--smart-account C... account the interpreter policy installs against;
|
|
84
|
+
required for the recording to lower to a predicate
|
|
85
|
+
document (amount caps, recipient allowlists, exact
|
|
86
|
+
hop paths) instead of warnings
|
|
100
87
|
--install-nonce per-rule install nonce for the interpreter policy
|
|
101
88
|
(default 1); requires --smart-account
|
|
102
|
-
--oracle-max-staleness interpreter oracle max staleness in seconds
|
|
103
|
-
(positive integer <= 600, tighten-only);
|
|
104
|
-
requires --smart-account
|
|
105
|
-
--oracle-max-deviation interpreter oracle max deviation in basis points
|
|
106
|
-
(positive integer <= 200, tighten-only);
|
|
107
|
-
requires --smart-account
|
|
108
|
-
--window-seconds per-field override of userResponses.windowSeconds;
|
|
109
|
-
overrides the same field from --responses (CLI flag
|
|
110
|
-
wins)
|
|
111
89
|
--valid-until per-field override of userResponses.validUntilLedger;
|
|
112
90
|
overrides the same field from --responses
|
|
113
91
|
--limit-amount per-field override of userResponses.limitAmount
|
|
114
92
|
(i128 decimal string); overrides the same field
|
|
115
93
|
from --responses
|
|
116
|
-
--invocation-limit per-field override of userResponses.invocationLimit;
|
|
117
|
-
overrides the same field from --responses
|
|
118
94
|
--recipient swap-recipient allowlist entry (C... contract or
|
|
119
95
|
G... wallet) for a SoroSwap swap; REPEATABLE. Absent,
|
|
120
96
|
the recorded recipient is pinned by default; supplying
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
// packages/policy-builder-cli/src/commands/synthesize.ts
|
|
2
2
|
//
|
|
3
3
|
// `policy-builder synthesize` subcommand. Dispatches to ONE of the two
|
|
4
|
-
// front-
|
|
5
|
-
// --mandate <path.json> -> synthesizeFromMandate
|
|
4
|
+
// One front-end:
|
|
6
5
|
// --recorded-tx <path.json> -> synthesizeFromRecording
|
|
7
6
|
//
|
|
8
7
|
// The CLI mirrors the MCP tool's discriminated union: one subcommand, two
|
|
9
8
|
// front-ends, mutually exclusive.
|
|
10
9
|
//
|
|
11
|
-
// Per-field response flags (--
|
|
12
|
-
//
|
|
10
|
+
// Per-field response flags (--valid-until, --limit-amount) merge into
|
|
11
|
+
// `userResponses`. A flag overrides the same
|
|
13
12
|
// field from --responses (CLI flags are explicit; the file is a default bag).
|
|
14
|
-
// Oracle params (--oracle-max-staleness, --oracle-max-deviation) are part of
|
|
15
13
|
// the interpreter opt-in and are rejected without --smart-account; tighten-only
|
|
16
14
|
// bounds are validated by the core.
|
|
17
15
|
//
|
|
18
16
|
// --explain (Phase 1) makes the synthesised policy human-readable. The
|
|
19
|
-
// orchestrator attaches the in-memory predicate tree
|
|
17
|
+
// orchestrator attaches the in-memory predicate tree
|
|
20
18
|
// to the success envelope; the CLI builds the deterministic review card
|
|
21
19
|
// from those inputs and emits it alongside the policy. Without --explain
|
|
22
20
|
// the output is byte-identical to today.
|
|
@@ -29,12 +27,10 @@ import { CliError, formatToolResponse, parsePairs, readJsonFile } from "../outpu
|
|
|
29
27
|
const POSITIVE_INT_RE = /^[0-9]+$/;
|
|
30
28
|
export async function runSynthesizeCommand(argv, flags) {
|
|
31
29
|
const pairs = parsePairs(argv);
|
|
32
|
-
|
|
33
|
-
const hasRecorded = Boolean(pairs['recorded-tx']);
|
|
34
|
-
if (hasMandate === hasRecorded) {
|
|
30
|
+
if (!pairs['recorded-tx']) {
|
|
35
31
|
throw new CliError({
|
|
36
32
|
code: 'CLI_MISSING_ARG',
|
|
37
|
-
message: 'synthesize:
|
|
33
|
+
message: 'synthesize: --recorded-tx <path> is required',
|
|
38
34
|
severity: 'error',
|
|
39
35
|
retryable: false,
|
|
40
36
|
});
|
|
@@ -45,16 +41,6 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
45
41
|
// output gains `review` + `predicateTree` and remains unchanged
|
|
46
42
|
// otherwise.
|
|
47
43
|
const explain = pairs.explain !== undefined;
|
|
48
|
-
if (hasMandate) {
|
|
49
|
-
const mandate = readJsonFile(pairs.mandate);
|
|
50
|
-
const args = { source: 'mandate', mandate };
|
|
51
|
-
applySharedFlags(args, pairs, explain);
|
|
52
|
-
const res = await runSynthesizePolicy(args);
|
|
53
|
-
if (explain)
|
|
54
|
-
emitExplainBlock(res, flags);
|
|
55
|
-
return formatToolResponse(res, flags, 'synthesize(mandate)');
|
|
56
|
-
}
|
|
57
|
-
// hasRecorded
|
|
58
44
|
const recordedFile = readJsonFile(pairs['recorded-tx']);
|
|
59
45
|
// Accept either a bare RecordedTransaction or the `{ ok, data }` artifact that
|
|
60
46
|
// `record --out` writes (same shape as `--json`), so `record --out X` followed
|
|
@@ -90,10 +76,8 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
90
76
|
// Per-field overrides. Each entry: argv flag name, userResponses key,
|
|
91
77
|
// and a parser that validates the raw string.
|
|
92
78
|
const userResponseFlags = [
|
|
93
|
-
['window-seconds', 'windowSeconds', parsePositiveInt],
|
|
94
79
|
['valid-until', 'validUntilLedger', parsePositiveInt],
|
|
95
80
|
['limit-amount', 'limitAmount', parseI128String],
|
|
96
|
-
['invocation-limit', 'invocationLimit', parsePositiveInt],
|
|
97
81
|
];
|
|
98
82
|
for (const [flag, key, parse] of userResponseFlags) {
|
|
99
83
|
if (pairs[flag] !== undefined) {
|
|
@@ -122,9 +106,10 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
122
106
|
if (Object.keys(userResponses).length > 0)
|
|
123
107
|
args.userResponses = userResponses;
|
|
124
108
|
applySharedFlags(args, pairs, explain);
|
|
125
|
-
// --smart-account <C...>
|
|
126
|
-
//
|
|
127
|
-
// exact hop paths)
|
|
109
|
+
// --smart-account <C...> names the account the interpreter policy installs
|
|
110
|
+
// against. It is what lets the recording lower to a real predicate document
|
|
111
|
+
// (amount caps, recipient allowlists, exact hop paths) rather than warnings;
|
|
112
|
+
// without it the core reports that no installable policy was synthesised.
|
|
128
113
|
// The core validates the address and installNonce; a bad value surfaces there.
|
|
129
114
|
//
|
|
130
115
|
// Use `!== undefined` (not truthy) so `--smart-account ""` and `--install-nonce`
|
|
@@ -134,9 +119,6 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
134
119
|
// when it had been silently skipped.
|
|
135
120
|
const smartAccountRaw = pairs['smart-account'];
|
|
136
121
|
const installNonceRaw = pairs['install-nonce'];
|
|
137
|
-
const oracleStalenessRaw = pairs['oracle-max-staleness'];
|
|
138
|
-
const oracleDeviationRaw = pairs['oracle-max-deviation'];
|
|
139
|
-
// --install-nonce and the oracle knobs are interpreter-only. Reject up
|
|
140
122
|
// front so they cannot be silently dropped when --smart-account is absent.
|
|
141
123
|
if (smartAccountRaw === undefined) {
|
|
142
124
|
if (installNonceRaw !== undefined) {
|
|
@@ -147,14 +129,6 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
147
129
|
retryable: false,
|
|
148
130
|
});
|
|
149
131
|
}
|
|
150
|
-
if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
|
|
151
|
-
throw new CliError({
|
|
152
|
-
code: 'CLI_MISSING_ARG',
|
|
153
|
-
message: 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
|
|
154
|
-
severity: 'error',
|
|
155
|
-
retryable: false,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
132
|
}
|
|
159
133
|
else {
|
|
160
134
|
const smartAccount = smartAccountRaw.trim();
|
|
@@ -187,19 +161,8 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
187
161
|
}
|
|
188
162
|
interpreter.installNonce = nonce;
|
|
189
163
|
}
|
|
190
|
-
// Oracle params only attach when at least one bound was provided. The
|
|
191
164
|
// core validates tighten-only (maxStalenessSeconds <= 600,
|
|
192
165
|
// maxDeviationBps <= 200) - a too-loose value surfaces as SYNTHESIS_ERROR.
|
|
193
|
-
if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
|
|
194
|
-
const oracleParams = {};
|
|
195
|
-
if (oracleStalenessRaw !== undefined) {
|
|
196
|
-
oracleParams.maxStalenessSeconds = parsePositiveInt(oracleStalenessRaw, '--oracle-max-staleness');
|
|
197
|
-
}
|
|
198
|
-
if (oracleDeviationRaw !== undefined) {
|
|
199
|
-
oracleParams.maxDeviationBps = parsePositiveInt(oracleDeviationRaw, '--oracle-max-deviation');
|
|
200
|
-
}
|
|
201
|
-
interpreter.oracleParams = oracleParams;
|
|
202
|
-
}
|
|
203
166
|
args.interpreter = interpreter;
|
|
204
167
|
}
|
|
205
168
|
if (explain)
|
|
@@ -213,13 +176,9 @@ export async function runSynthesizeCommand(argv, flags) {
|
|
|
213
176
|
emitExplainBlock(res, flags);
|
|
214
177
|
return formatToolResponse(res, flags, 'synthesize(recording)');
|
|
215
178
|
}
|
|
216
|
-
/** Apply flags
|
|
217
|
-
* --
|
|
218
|
-
* adds one branch here rather than two copies in the command body. */
|
|
179
|
+
/** Apply the flags that are not part of the recording payload itself:
|
|
180
|
+
* --confidence, --explain. */
|
|
219
181
|
function applySharedFlags(args, pairs, explain) {
|
|
220
|
-
if (pairs['oz-config'] !== undefined) {
|
|
221
|
-
args.ozConfig = readOzConfigFile(pairs['oz-config']);
|
|
222
|
-
}
|
|
223
182
|
if (pairs.confidence !== undefined) {
|
|
224
183
|
args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
|
|
225
184
|
}
|
|
@@ -239,15 +198,15 @@ function emitExplainBlock(res, flags) {
|
|
|
239
198
|
const review = buildReviewCardSummary(
|
|
240
199
|
// The orchestrator's `predicateTree` is the exact in-memory AST
|
|
241
200
|
// (canonical JSON shape). The builder's input is typed as
|
|
242
|
-
// `PredicateNode | null`;
|
|
243
|
-
//
|
|
244
|
-
(res.explain.predicateTree ?? null), res.data?.
|
|
201
|
+
// `PredicateNode | null`; a null is the truthful value when the
|
|
202
|
+
// interpreter adapter was not engaged.
|
|
203
|
+
(res.explain.predicateTree ?? null), res.data?.contextRule ?? {
|
|
245
204
|
contextRuleType: { kind: 'default' },
|
|
246
205
|
name: 'unknown',
|
|
247
206
|
validUntilLedger: null,
|
|
248
207
|
signers: [],
|
|
249
208
|
policies: [],
|
|
250
|
-
},
|
|
209
|
+
}, 'interpreter-v1');
|
|
251
210
|
res.data.review =
|
|
252
211
|
review;
|
|
253
212
|
res.data.predicateTree = res.explain.predicateTree;
|
|
@@ -269,23 +228,6 @@ function emitExplainBlock(res, flags) {
|
|
|
269
228
|
}
|
|
270
229
|
}
|
|
271
230
|
}
|
|
272
|
-
/** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
|
|
273
|
-
* CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
|
|
274
|
-
* on `ozConfig` catches shape mismatches downstream. */
|
|
275
|
-
function readOzConfigFile(path) {
|
|
276
|
-
const value = readJsonFile(path);
|
|
277
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
278
|
-
throw new CliError({
|
|
279
|
-
code: 'CLI_INVALID_JSON',
|
|
280
|
-
message: `synthesize: --oz-config ${path} must be a JSON object`,
|
|
281
|
-
severity: 'error',
|
|
282
|
-
retryable: false,
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
return value;
|
|
286
|
-
}
|
|
287
|
-
/** Parse and validate `--confidence <n>` as a finite number in [0, 1]. A
|
|
288
|
-
* threshold above 1 would disable the recorder gate; reject it up front. */
|
|
289
231
|
function parseConfidence(raw) {
|
|
290
232
|
const n = Number(raw);
|
|
291
233
|
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
@@ -298,8 +240,7 @@ function parseConfidence(raw) {
|
|
|
298
240
|
}
|
|
299
241
|
return n;
|
|
300
242
|
}
|
|
301
|
-
/** Parse a strictly positive integer (
|
|
302
|
-
* invocationLimit, oracleParams bounds). The core re-validates these with
|
|
243
|
+
/** Parse a strictly positive integer (validUntilLedger,
|
|
303
244
|
* field-specific caps; the CLI just enforces "looks like an integer > 0".
|
|
304
245
|
* The `^[0-9]+$` regex already pins the shape to a non-negative integer, so
|
|
305
246
|
* the only thing left to check is "not zero". */
|
|
@@ -2,22 +2,20 @@
|
|
|
2
2
|
// packages/policy-builder-cli/src/commands/synthesize.ts
|
|
3
3
|
//
|
|
4
4
|
// `policy-builder synthesize` subcommand. Dispatches to ONE of the two
|
|
5
|
-
// front-
|
|
6
|
-
// --mandate <path.json> -> synthesizeFromMandate
|
|
5
|
+
// One front-end:
|
|
7
6
|
// --recorded-tx <path.json> -> synthesizeFromRecording
|
|
8
7
|
//
|
|
9
8
|
// The CLI mirrors the MCP tool's discriminated union: one subcommand, two
|
|
10
9
|
// front-ends, mutually exclusive.
|
|
11
10
|
//
|
|
12
|
-
// Per-field response flags (--
|
|
13
|
-
//
|
|
11
|
+
// Per-field response flags (--valid-until, --limit-amount) merge into
|
|
12
|
+
// `userResponses`. A flag overrides the same
|
|
14
13
|
// field from --responses (CLI flags are explicit; the file is a default bag).
|
|
15
|
-
// Oracle params (--oracle-max-staleness, --oracle-max-deviation) are part of
|
|
16
14
|
// the interpreter opt-in and are rejected without --smart-account; tighten-only
|
|
17
15
|
// bounds are validated by the core.
|
|
18
16
|
//
|
|
19
17
|
// --explain (Phase 1) makes the synthesised policy human-readable. The
|
|
20
|
-
// orchestrator attaches the in-memory predicate tree
|
|
18
|
+
// orchestrator attaches the in-memory predicate tree
|
|
21
19
|
// to the success envelope; the CLI builds the deterministic review card
|
|
22
20
|
// from those inputs and emits it alongside the policy. Without --explain
|
|
23
21
|
// the output is byte-identical to today.
|
|
@@ -32,12 +30,10 @@ const output_ts_1 = require("../output.js");
|
|
|
32
30
|
const POSITIVE_INT_RE = /^[0-9]+$/;
|
|
33
31
|
async function runSynthesizeCommand(argv, flags) {
|
|
34
32
|
const pairs = (0, output_ts_1.parsePairs)(argv);
|
|
35
|
-
|
|
36
|
-
const hasRecorded = Boolean(pairs['recorded-tx']);
|
|
37
|
-
if (hasMandate === hasRecorded) {
|
|
33
|
+
if (!pairs['recorded-tx']) {
|
|
38
34
|
throw new output_ts_1.CliError({
|
|
39
35
|
code: 'CLI_MISSING_ARG',
|
|
40
|
-
message: 'synthesize:
|
|
36
|
+
message: 'synthesize: --recorded-tx <path> is required',
|
|
41
37
|
severity: 'error',
|
|
42
38
|
retryable: false,
|
|
43
39
|
});
|
|
@@ -48,16 +44,6 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
48
44
|
// output gains `review` + `predicateTree` and remains unchanged
|
|
49
45
|
// otherwise.
|
|
50
46
|
const explain = pairs.explain !== undefined;
|
|
51
|
-
if (hasMandate) {
|
|
52
|
-
const mandate = (0, output_ts_1.readJsonFile)(pairs.mandate);
|
|
53
|
-
const args = { source: 'mandate', mandate };
|
|
54
|
-
applySharedFlags(args, pairs, explain);
|
|
55
|
-
const res = await (0, run_1.runSynthesizePolicy)(args);
|
|
56
|
-
if (explain)
|
|
57
|
-
emitExplainBlock(res, flags);
|
|
58
|
-
return (0, output_ts_1.formatToolResponse)(res, flags, 'synthesize(mandate)');
|
|
59
|
-
}
|
|
60
|
-
// hasRecorded
|
|
61
47
|
const recordedFile = (0, output_ts_1.readJsonFile)(pairs['recorded-tx']);
|
|
62
48
|
// Accept either a bare RecordedTransaction or the `{ ok, data }` artifact that
|
|
63
49
|
// `record --out` writes (same shape as `--json`), so `record --out X` followed
|
|
@@ -93,10 +79,8 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
93
79
|
// Per-field overrides. Each entry: argv flag name, userResponses key,
|
|
94
80
|
// and a parser that validates the raw string.
|
|
95
81
|
const userResponseFlags = [
|
|
96
|
-
['window-seconds', 'windowSeconds', parsePositiveInt],
|
|
97
82
|
['valid-until', 'validUntilLedger', parsePositiveInt],
|
|
98
83
|
['limit-amount', 'limitAmount', parseI128String],
|
|
99
|
-
['invocation-limit', 'invocationLimit', parsePositiveInt],
|
|
100
84
|
];
|
|
101
85
|
for (const [flag, key, parse] of userResponseFlags) {
|
|
102
86
|
if (pairs[flag] !== undefined) {
|
|
@@ -125,9 +109,10 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
125
109
|
if (Object.keys(userResponses).length > 0)
|
|
126
110
|
args.userResponses = userResponses;
|
|
127
111
|
applySharedFlags(args, pairs, explain);
|
|
128
|
-
// --smart-account <C...>
|
|
129
|
-
//
|
|
130
|
-
// exact hop paths)
|
|
112
|
+
// --smart-account <C...> names the account the interpreter policy installs
|
|
113
|
+
// against. It is what lets the recording lower to a real predicate document
|
|
114
|
+
// (amount caps, recipient allowlists, exact hop paths) rather than warnings;
|
|
115
|
+
// without it the core reports that no installable policy was synthesised.
|
|
131
116
|
// The core validates the address and installNonce; a bad value surfaces there.
|
|
132
117
|
//
|
|
133
118
|
// Use `!== undefined` (not truthy) so `--smart-account ""` and `--install-nonce`
|
|
@@ -137,9 +122,6 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
137
122
|
// when it had been silently skipped.
|
|
138
123
|
const smartAccountRaw = pairs['smart-account'];
|
|
139
124
|
const installNonceRaw = pairs['install-nonce'];
|
|
140
|
-
const oracleStalenessRaw = pairs['oracle-max-staleness'];
|
|
141
|
-
const oracleDeviationRaw = pairs['oracle-max-deviation'];
|
|
142
|
-
// --install-nonce and the oracle knobs are interpreter-only. Reject up
|
|
143
125
|
// front so they cannot be silently dropped when --smart-account is absent.
|
|
144
126
|
if (smartAccountRaw === undefined) {
|
|
145
127
|
if (installNonceRaw !== undefined) {
|
|
@@ -150,14 +132,6 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
150
132
|
retryable: false,
|
|
151
133
|
});
|
|
152
134
|
}
|
|
153
|
-
if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
|
|
154
|
-
throw new output_ts_1.CliError({
|
|
155
|
-
code: 'CLI_MISSING_ARG',
|
|
156
|
-
message: 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
|
|
157
|
-
severity: 'error',
|
|
158
|
-
retryable: false,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
135
|
}
|
|
162
136
|
else {
|
|
163
137
|
const smartAccount = smartAccountRaw.trim();
|
|
@@ -190,19 +164,8 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
190
164
|
}
|
|
191
165
|
interpreter.installNonce = nonce;
|
|
192
166
|
}
|
|
193
|
-
// Oracle params only attach when at least one bound was provided. The
|
|
194
167
|
// core validates tighten-only (maxStalenessSeconds <= 600,
|
|
195
168
|
// maxDeviationBps <= 200) - a too-loose value surfaces as SYNTHESIS_ERROR.
|
|
196
|
-
if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
|
|
197
|
-
const oracleParams = {};
|
|
198
|
-
if (oracleStalenessRaw !== undefined) {
|
|
199
|
-
oracleParams.maxStalenessSeconds = parsePositiveInt(oracleStalenessRaw, '--oracle-max-staleness');
|
|
200
|
-
}
|
|
201
|
-
if (oracleDeviationRaw !== undefined) {
|
|
202
|
-
oracleParams.maxDeviationBps = parsePositiveInt(oracleDeviationRaw, '--oracle-max-deviation');
|
|
203
|
-
}
|
|
204
|
-
interpreter.oracleParams = oracleParams;
|
|
205
|
-
}
|
|
206
169
|
args.interpreter = interpreter;
|
|
207
170
|
}
|
|
208
171
|
if (explain)
|
|
@@ -216,13 +179,9 @@ async function runSynthesizeCommand(argv, flags) {
|
|
|
216
179
|
emitExplainBlock(res, flags);
|
|
217
180
|
return (0, output_ts_1.formatToolResponse)(res, flags, 'synthesize(recording)');
|
|
218
181
|
}
|
|
219
|
-
/** Apply flags
|
|
220
|
-
* --
|
|
221
|
-
* adds one branch here rather than two copies in the command body. */
|
|
182
|
+
/** Apply the flags that are not part of the recording payload itself:
|
|
183
|
+
* --confidence, --explain. */
|
|
222
184
|
function applySharedFlags(args, pairs, explain) {
|
|
223
|
-
if (pairs['oz-config'] !== undefined) {
|
|
224
|
-
args.ozConfig = readOzConfigFile(pairs['oz-config']);
|
|
225
|
-
}
|
|
226
185
|
if (pairs.confidence !== undefined) {
|
|
227
186
|
args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
|
|
228
187
|
}
|
|
@@ -242,15 +201,15 @@ function emitExplainBlock(res, flags) {
|
|
|
242
201
|
const review = (0, policy_synth_1.buildReviewCardSummary)(
|
|
243
202
|
// The orchestrator's `predicateTree` is the exact in-memory AST
|
|
244
203
|
// (canonical JSON shape). The builder's input is typed as
|
|
245
|
-
// `PredicateNode | null`;
|
|
246
|
-
//
|
|
247
|
-
(res.explain.predicateTree ?? null), res.data?.
|
|
204
|
+
// `PredicateNode | null`; a null is the truthful value when the
|
|
205
|
+
// interpreter adapter was not engaged.
|
|
206
|
+
(res.explain.predicateTree ?? null), res.data?.contextRule ?? {
|
|
248
207
|
contextRuleType: { kind: 'default' },
|
|
249
208
|
name: 'unknown',
|
|
250
209
|
validUntilLedger: null,
|
|
251
210
|
signers: [],
|
|
252
211
|
policies: [],
|
|
253
|
-
},
|
|
212
|
+
}, 'interpreter-v1');
|
|
254
213
|
res.data.review =
|
|
255
214
|
review;
|
|
256
215
|
res.data.predicateTree = res.explain.predicateTree;
|
|
@@ -272,23 +231,6 @@ function emitExplainBlock(res, flags) {
|
|
|
272
231
|
}
|
|
273
232
|
}
|
|
274
233
|
}
|
|
275
|
-
/** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
|
|
276
|
-
* CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
|
|
277
|
-
* on `ozConfig` catches shape mismatches downstream. */
|
|
278
|
-
function readOzConfigFile(path) {
|
|
279
|
-
const value = (0, output_ts_1.readJsonFile)(path);
|
|
280
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
281
|
-
throw new output_ts_1.CliError({
|
|
282
|
-
code: 'CLI_INVALID_JSON',
|
|
283
|
-
message: `synthesize: --oz-config ${path} must be a JSON object`,
|
|
284
|
-
severity: 'error',
|
|
285
|
-
retryable: false,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
return value;
|
|
289
|
-
}
|
|
290
|
-
/** Parse and validate `--confidence <n>` as a finite number in [0, 1]. A
|
|
291
|
-
* threshold above 1 would disable the recorder gate; reject it up front. */
|
|
292
234
|
function parseConfidence(raw) {
|
|
293
235
|
const n = Number(raw);
|
|
294
236
|
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
@@ -301,8 +243,7 @@ function parseConfidence(raw) {
|
|
|
301
243
|
}
|
|
302
244
|
return n;
|
|
303
245
|
}
|
|
304
|
-
/** Parse a strictly positive integer (
|
|
305
|
-
* invocationLimit, oracleParams bounds). The core re-validates these with
|
|
246
|
+
/** Parse a strictly positive integer (validUntilLedger,
|
|
306
247
|
* field-specific caps; the CLI just enforces "looks like an integer > 0".
|
|
307
248
|
* The `^[0-9]+$` regex already pins the shape to a non-negative integer, so
|
|
308
249
|
* the only thing left to check is "not zero". */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-builder-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "CLI wrapper around the OZ policy-synth core (record + synthesize) for solo-dev and CI workflows.",
|
|
6
6
|
"type": "module",
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
},
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
|
-
"url": "https://github.com/untangledfinance/
|
|
15
|
+
"url": "https://github.com/untangledfinance/oz-policy-builder.git",
|
|
16
16
|
"directory": "packages/policy-builder-cli"
|
|
17
17
|
},
|
|
18
|
-
"homepage": "https://github.com/untangledfinance/
|
|
18
|
+
"homepage": "https://github.com/untangledfinance/oz-policy-builder#readme",
|
|
19
19
|
"bugs": {
|
|
20
|
-
"url": "https://github.com/untangledfinance/
|
|
20
|
+
"url": "https://github.com/untangledfinance/oz-policy-builder/issues"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"stellar",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"prepack": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@crediolabs/policy-synth": "0.
|
|
66
|
+
"@crediolabs/policy-synth": "0.3.0",
|
|
67
67
|
"@stellar/stellar-sdk": "14.4.0",
|
|
68
68
|
"zod": "3.25.76"
|
|
69
69
|
},
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
// packages/policy-builder-cli/src/commands/synthesize.ts
|
|
2
2
|
//
|
|
3
3
|
// `policy-builder synthesize` subcommand. Dispatches to ONE of the two
|
|
4
|
-
// front-
|
|
5
|
-
// --mandate <path.json> -> synthesizeFromMandate
|
|
4
|
+
// One front-end:
|
|
6
5
|
// --recorded-tx <path.json> -> synthesizeFromRecording
|
|
7
6
|
//
|
|
8
7
|
// The CLI mirrors the MCP tool's discriminated union: one subcommand, two
|
|
9
8
|
// front-ends, mutually exclusive.
|
|
10
9
|
//
|
|
11
|
-
// Per-field response flags (--
|
|
12
|
-
//
|
|
10
|
+
// Per-field response flags (--valid-until, --limit-amount) merge into
|
|
11
|
+
// `userResponses`. A flag overrides the same
|
|
13
12
|
// field from --responses (CLI flags are explicit; the file is a default bag).
|
|
14
|
-
// Oracle params (--oracle-max-staleness, --oracle-max-deviation) are part of
|
|
15
13
|
// the interpreter opt-in and are rejected without --smart-account; tighten-only
|
|
16
14
|
// bounds are validated by the core.
|
|
17
15
|
//
|
|
18
16
|
// --explain (Phase 1) makes the synthesised policy human-readable. The
|
|
19
|
-
// orchestrator attaches the in-memory predicate tree
|
|
17
|
+
// orchestrator attaches the in-memory predicate tree
|
|
20
18
|
// to the success envelope; the CLI builds the deterministic review card
|
|
21
19
|
// from those inputs and emits it alongside the policy. Without --explain
|
|
22
20
|
// the output is byte-identical to today.
|
|
@@ -30,17 +28,6 @@ import {
|
|
|
30
28
|
import { runSynthesizePolicy } from '@crediolabs/policy-synth/run'
|
|
31
29
|
import { CliError, type CliFlags, formatToolResponse, parsePairs, readJsonFile } from '../output.ts'
|
|
32
30
|
|
|
33
|
-
/** Local mirror of the synth core's `SimulationResult` shape. The verify
|
|
34
|
-
* module is internal to the package, so the CLI keeps this minimal copy
|
|
35
|
-
* instead of importing the deep path - the contract is small enough that
|
|
36
|
-
* a structural type is cheaper than a new package export. */
|
|
37
|
-
type SimulationResult = {
|
|
38
|
-
permit: { tx: 'permit' } | { tx: 'deny'; reason: string }
|
|
39
|
-
evaluatedCases: Array<{ dimension: string; outcome: 'permit' | 'deny'; reason: string }>
|
|
40
|
-
backend: 'interpreter-v1' | 'ts-model'
|
|
41
|
-
simulatorVersion: string
|
|
42
|
-
}
|
|
43
|
-
|
|
44
31
|
// Positive-int flags and i128 amount strings share the same wire shape: a
|
|
45
32
|
// base-10 unsigned decimal, no sign. The i128 stays a string at the boundary
|
|
46
33
|
// because it is wider than Number.MAX_SAFE_INTEGER.
|
|
@@ -51,12 +38,10 @@ export async function runSynthesizeCommand(
|
|
|
51
38
|
flags: CliFlags
|
|
52
39
|
): Promise<ProposedPolicy> {
|
|
53
40
|
const pairs = parsePairs(argv)
|
|
54
|
-
|
|
55
|
-
const hasRecorded = Boolean(pairs['recorded-tx'])
|
|
56
|
-
if (hasMandate === hasRecorded) {
|
|
41
|
+
if (!pairs['recorded-tx']) {
|
|
57
42
|
throw new CliError({
|
|
58
43
|
code: 'CLI_MISSING_ARG',
|
|
59
|
-
message: 'synthesize:
|
|
44
|
+
message: 'synthesize: --recorded-tx <path> is required',
|
|
60
45
|
severity: 'error',
|
|
61
46
|
retryable: false,
|
|
62
47
|
})
|
|
@@ -69,16 +54,6 @@ export async function runSynthesizeCommand(
|
|
|
69
54
|
// otherwise.
|
|
70
55
|
const explain = pairs.explain !== undefined
|
|
71
56
|
|
|
72
|
-
if (hasMandate) {
|
|
73
|
-
const mandate = readJsonFile(pairs.mandate as string) as Record<string, unknown>
|
|
74
|
-
const args: Record<string, unknown> = { source: 'mandate', mandate }
|
|
75
|
-
applySharedFlags(args, pairs, explain)
|
|
76
|
-
const res = await runSynthesizePolicy(args)
|
|
77
|
-
if (explain) emitExplainBlock(res, flags)
|
|
78
|
-
return formatToolResponse(res, flags, 'synthesize(mandate)')
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// hasRecorded
|
|
82
57
|
const recordedFile = readJsonFile(pairs['recorded-tx'] as string) as Record<string, unknown>
|
|
83
58
|
// Accept either a bare RecordedTransaction or the `{ ok, data }` artifact that
|
|
84
59
|
// `record --out` writes (same shape as `--json`), so `record --out X` followed
|
|
@@ -116,10 +91,8 @@ export async function runSynthesizeCommand(
|
|
|
116
91
|
// Per-field overrides. Each entry: argv flag name, userResponses key,
|
|
117
92
|
// and a parser that validates the raw string.
|
|
118
93
|
const userResponseFlags: Array<[string, string, (raw: string, flag: string) => unknown]> = [
|
|
119
|
-
['window-seconds', 'windowSeconds', parsePositiveInt],
|
|
120
94
|
['valid-until', 'validUntilLedger', parsePositiveInt],
|
|
121
95
|
['limit-amount', 'limitAmount', parseI128String],
|
|
122
|
-
['invocation-limit', 'invocationLimit', parsePositiveInt],
|
|
123
96
|
]
|
|
124
97
|
for (const [flag, key, parse] of userResponseFlags) {
|
|
125
98
|
if (pairs[flag] !== undefined) {
|
|
@@ -148,9 +121,10 @@ export async function runSynthesizeCommand(
|
|
|
148
121
|
|
|
149
122
|
applySharedFlags(args, pairs, explain)
|
|
150
123
|
|
|
151
|
-
// --smart-account <C...>
|
|
152
|
-
//
|
|
153
|
-
// exact hop paths)
|
|
124
|
+
// --smart-account <C...> names the account the interpreter policy installs
|
|
125
|
+
// against. It is what lets the recording lower to a real predicate document
|
|
126
|
+
// (amount caps, recipient allowlists, exact hop paths) rather than warnings;
|
|
127
|
+
// without it the core reports that no installable policy was synthesised.
|
|
154
128
|
// The core validates the address and installNonce; a bad value surfaces there.
|
|
155
129
|
//
|
|
156
130
|
// Use `!== undefined` (not truthy) so `--smart-account ""` and `--install-nonce`
|
|
@@ -160,9 +134,6 @@ export async function runSynthesizeCommand(
|
|
|
160
134
|
// when it had been silently skipped.
|
|
161
135
|
const smartAccountRaw = pairs['smart-account']
|
|
162
136
|
const installNonceRaw = pairs['install-nonce']
|
|
163
|
-
const oracleStalenessRaw = pairs['oracle-max-staleness']
|
|
164
|
-
const oracleDeviationRaw = pairs['oracle-max-deviation']
|
|
165
|
-
// --install-nonce and the oracle knobs are interpreter-only. Reject up
|
|
166
137
|
// front so they cannot be silently dropped when --smart-account is absent.
|
|
167
138
|
if (smartAccountRaw === undefined) {
|
|
168
139
|
if (installNonceRaw !== undefined) {
|
|
@@ -173,15 +144,6 @@ export async function runSynthesizeCommand(
|
|
|
173
144
|
retryable: false,
|
|
174
145
|
})
|
|
175
146
|
}
|
|
176
|
-
if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
|
|
177
|
-
throw new CliError({
|
|
178
|
-
code: 'CLI_MISSING_ARG',
|
|
179
|
-
message:
|
|
180
|
-
'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
|
|
181
|
-
severity: 'error',
|
|
182
|
-
retryable: false,
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
147
|
} else {
|
|
186
148
|
const smartAccount = smartAccountRaw.trim()
|
|
187
149
|
if (smartAccount.length === 0) {
|
|
@@ -214,25 +176,8 @@ export async function runSynthesizeCommand(
|
|
|
214
176
|
}
|
|
215
177
|
interpreter.installNonce = nonce
|
|
216
178
|
}
|
|
217
|
-
// Oracle params only attach when at least one bound was provided. The
|
|
218
179
|
// core validates tighten-only (maxStalenessSeconds <= 600,
|
|
219
180
|
// maxDeviationBps <= 200) - a too-loose value surfaces as SYNTHESIS_ERROR.
|
|
220
|
-
if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
|
|
221
|
-
const oracleParams: Record<string, number> = {}
|
|
222
|
-
if (oracleStalenessRaw !== undefined) {
|
|
223
|
-
oracleParams.maxStalenessSeconds = parsePositiveInt(
|
|
224
|
-
oracleStalenessRaw,
|
|
225
|
-
'--oracle-max-staleness'
|
|
226
|
-
)
|
|
227
|
-
}
|
|
228
|
-
if (oracleDeviationRaw !== undefined) {
|
|
229
|
-
oracleParams.maxDeviationBps = parsePositiveInt(
|
|
230
|
-
oracleDeviationRaw,
|
|
231
|
-
'--oracle-max-deviation'
|
|
232
|
-
)
|
|
233
|
-
}
|
|
234
|
-
interpreter.oracleParams = oracleParams
|
|
235
|
-
}
|
|
236
181
|
args.interpreter = interpreter
|
|
237
182
|
}
|
|
238
183
|
if (explain) args.explain = true
|
|
@@ -245,17 +190,13 @@ export async function runSynthesizeCommand(
|
|
|
245
190
|
return formatToolResponse(res, flags, 'synthesize(recording)')
|
|
246
191
|
}
|
|
247
192
|
|
|
248
|
-
/** Apply flags
|
|
249
|
-
* --
|
|
250
|
-
* adds one branch here rather than two copies in the command body. */
|
|
193
|
+
/** Apply the flags that are not part of the recording payload itself:
|
|
194
|
+
* --confidence, --explain. */
|
|
251
195
|
function applySharedFlags(
|
|
252
196
|
args: Record<string, unknown>,
|
|
253
197
|
pairs: Record<string, string>,
|
|
254
198
|
explain: boolean
|
|
255
199
|
): void {
|
|
256
|
-
if (pairs['oz-config'] !== undefined) {
|
|
257
|
-
args.ozConfig = readOzConfigFile(pairs['oz-config'] as string)
|
|
258
|
-
}
|
|
259
200
|
if (pairs.confidence !== undefined) {
|
|
260
201
|
args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
|
|
261
202
|
}
|
|
@@ -275,7 +216,6 @@ function emitExplainBlock(
|
|
|
275
216
|
data?: ProposedPolicy
|
|
276
217
|
explain?: {
|
|
277
218
|
predicateTree: unknown
|
|
278
|
-
simulation: SimulationResult
|
|
279
219
|
}
|
|
280
220
|
},
|
|
281
221
|
flags: CliFlags
|
|
@@ -284,10 +224,9 @@ function emitExplainBlock(
|
|
|
284
224
|
const review = buildReviewCardSummary(
|
|
285
225
|
// The orchestrator's `predicateTree` is the exact in-memory AST
|
|
286
226
|
// (canonical JSON shape). The builder's input is typed as
|
|
287
|
-
// `PredicateNode | null`;
|
|
288
|
-
//
|
|
227
|
+
// `PredicateNode | null`; a null is the truthful value when the
|
|
228
|
+
// interpreter adapter was not engaged.
|
|
289
229
|
(res.explain.predicateTree ?? null) as never,
|
|
290
|
-
res.data?.policyRefs ?? [],
|
|
291
230
|
res.data?.contextRule ?? {
|
|
292
231
|
contextRuleType: { kind: 'default' as const },
|
|
293
232
|
name: 'unknown',
|
|
@@ -295,7 +234,7 @@ function emitExplainBlock(
|
|
|
295
234
|
signers: [],
|
|
296
235
|
policies: [],
|
|
297
236
|
},
|
|
298
|
-
|
|
237
|
+
'interpreter-v1'
|
|
299
238
|
)
|
|
300
239
|
// Attach the two additive fields to the on-wire envelope. formatToolResponse
|
|
301
240
|
// reads from `res` and writes the JSON; we mutate the same object so the
|
|
@@ -324,24 +263,6 @@ function emitExplainBlock(
|
|
|
324
263
|
}
|
|
325
264
|
}
|
|
326
265
|
|
|
327
|
-
/** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
|
|
328
|
-
* CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
|
|
329
|
-
* on `ozConfig` catches shape mismatches downstream. */
|
|
330
|
-
function readOzConfigFile(path: string): Record<string, unknown> {
|
|
331
|
-
const value = readJsonFile(path)
|
|
332
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
333
|
-
throw new CliError({
|
|
334
|
-
code: 'CLI_INVALID_JSON',
|
|
335
|
-
message: `synthesize: --oz-config ${path} must be a JSON object`,
|
|
336
|
-
severity: 'error',
|
|
337
|
-
retryable: false,
|
|
338
|
-
})
|
|
339
|
-
}
|
|
340
|
-
return value as Record<string, unknown>
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** Parse and validate `--confidence <n>` as a finite number in [0, 1]. A
|
|
344
|
-
* threshold above 1 would disable the recorder gate; reject it up front. */
|
|
345
266
|
function parseConfidence(raw: string): number {
|
|
346
267
|
const n = Number(raw)
|
|
347
268
|
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
@@ -355,8 +276,7 @@ function parseConfidence(raw: string): number {
|
|
|
355
276
|
return n
|
|
356
277
|
}
|
|
357
278
|
|
|
358
|
-
/** Parse a strictly positive integer (
|
|
359
|
-
* invocationLimit, oracleParams bounds). The core re-validates these with
|
|
279
|
+
/** Parse a strictly positive integer (validUntilLedger,
|
|
360
280
|
* field-specific caps; the CLI just enforces "looks like an integer > 0".
|
|
361
281
|
* The `^[0-9]+$` regex already pins the shape to a non-negative integer, so
|
|
362
282
|
* the only thing left to check is "not zero". */
|