@crediolabs/policy-builder-cli 0.1.17 → 0.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/README.md CHANGED
@@ -1,132 +1,69 @@
1
1
  # @crediolabs/policy-builder-cli
2
2
 
3
- CLI for the OpenZeppelin Accounts Policy Builder. It records a Soroban
4
- transaction and synthesises the minimal policy that permits exactly that flow —
5
- the same core as the MCP server, for solo-dev and CI workflows. MIT-licensed.
3
+ Command-line wrapper around
4
+ [`@crediolabs/policy-synth`](https://www.npmjs.com/package/@crediolabs/policy-synth):
5
+ record a Soroban transaction and synthesise the minimal policy that permits
6
+ exactly that flow, from a shell or a CI job.
6
7
 
7
8
  ## Install
8
9
 
9
10
  ```sh
10
11
  npm install -g @crediolabs/policy-builder-cli
11
- # or run without installing:
12
- npx @crediolabs/policy-builder-cli --help
12
+ # or run without installing
13
+ bunx @crediolabs/policy-builder-cli record --network testnet --hash <tx>
13
14
  ```
14
15
 
16
+ The binary is `policy-builder`.
17
+
15
18
  ## Commands
16
19
 
17
- ```
18
- policy-builder record --network <mainnet|testnet> --hash <tx> | --xdr <b64>
19
- [--json] [--quiet] [--out <path>]
20
-
21
- policy-builder synthesize --mandate <path.json>
22
- [--oz-config <path.json>] [--confidence <0..1>]
23
- [--json] [--quiet] [--out <path>]
24
-
25
- policy-builder synthesize --recorded-tx <path.json> --network <mainnet|testnet>
26
- [--responses <path.json>]
27
- [--oz-config <path.json>] [--confidence <0..1>]
28
- [--smart-account <C...>] [--install-nonce <n>]
29
- [--oracle-max-staleness <s>] [--oracle-max-deviation <bps>]
30
- [--window-seconds <n>] [--valid-until <ledger>]
31
- [--limit-amount <i128str>] [--invocation-limit <n>]
32
- [--json] [--quiet] [--out <path>]
33
- ```
20
+ ### `record`
34
21
 
35
- Global flags:
36
-
37
- - `--json` — emit a machine-readable JSON envelope (`{ ok, data }` / `{ ok, error }`) on stdout.
38
- - `--quiet` — suppress progress / non-error output.
39
- - `--out <path>` — write the artefact to a file (JSON).
40
- - `--oz-config <path.json>` — JSON `OzAdapterConfig` (custom OZ instance
41
- addresses for the network). Defaults to the core's mainnet placeholder.
42
- Applies to both the `--mandate` and `--recorded-tx` synthesize paths.
43
- - `--confidence <0..1>` — recorder confidence gate threshold. The synth
44
- rejects when `parseConfidence.overall < threshold`; it does not change the
45
- recording's `parseConfidence.thresholdUsed` in the output. Must be a finite
46
- number in `[0, 1]` (inclusive); out-of-range values are rejected with
47
- `CLI_MISSING_ARG`.
48
- - `--smart-account <C...>` — opt into the interpreter adapter, so
49
- constraints OZ built-ins cannot express (per-method scoping, invocation-count
50
- windows, oracle bounds, exact hop paths) lower to a real predicate document
51
- instead of warnings. Must be a Stellar contract strkey (56 chars, base32
52
- alphabet `[2-7A-Z]`, matching `^C[2-7A-Z]{55}$`).
53
- - `--install-nonce <n>` — per-rule install nonce for the interpreter policy.
54
- Requires `--smart-account`; the CLI rejects `--install-nonce` without
55
- `--smart-account` so callers cannot silently drop the nonce.
56
- - `--oracle-max-staleness <s>` — interpreter oracle max staleness in seconds
57
- (positive integer `<= 600`, tighten-only vs the wasm default). Requires
58
- `--smart-account`; widen values are rejected by the core.
59
- - `--oracle-max-deviation <bps>` — interpreter oracle max deviation in basis
60
- points (positive integer `<= 200`, tighten-only). Requires
61
- `--smart-account`; widen values are rejected by the core.
62
- - `--window-seconds <n>` — per-field override of `userResponses.windowSeconds`.
63
- Overrides the same field from `--responses` (CLI flag wins).
64
- - `--valid-until <ledger>` — per-field override of `userResponses.validUntilLedger`.
65
- Overrides the same field from `--responses` (CLI flag wins).
66
- - `--limit-amount <i128str>` — per-field override of `userResponses.limitAmount`
67
- (positive base-10 i128 string). Overrides the same field from `--responses`.
68
- - `--invocation-limit <n>` — per-field override of `userResponses.invocationLimit`.
69
- Overrides the same field from `--responses` (CLI flag wins).
70
-
71
- **Precedence for per-field response flags:** `--responses <file>` provides the
72
- base `userResponses` object; any of the per-field flags above override the
73
- same-named field from the file (the CLI flag is the explicit caller intent,
74
- the file is the default bag). The two never merge - either-or on the same
75
- field.
76
-
77
- A `ToolError` exits non-zero (`1`), so CI scripts can gate on `$?`. Empty
78
- `--smart-account`, out-of-range `--confidence`, non-numeric `--window-seconds`,
79
- non-integer `--oracle-*`, and `--oracle-*` without `--smart-account` are all
80
- rejected with `CLI_MISSING_ARG` before the call reaches the core.
81
-
82
- ## Examples
83
-
84
- Record an on-chain transaction and synthesise from the recording:
22
+ Decode a transaction into a `RecordedTransaction`:
85
23
 
86
24
  ```sh
87
- policy-builder record --network mainnet --hash <tx-hash> --out recorded-tx.json
88
- policy-builder synthesize --recorded-tx recorded-tx.json --network mainnet --json
25
+ policy-builder record --network mainnet --hash <transaction hash>
26
+ policy-builder record --network testnet --xdr <base64 envelope XDR>
89
27
  ```
90
28
 
91
- Synthesise with the interpreter opt-in (use when the policy needs constraints
92
- OZ built-ins cannot express — e.g. per-claim frequency caps on Blend):
29
+ Exactly one of `--hash` (fetched over RPC) or `--xdr` (decoded locally) is
30
+ required.
31
+
32
+ ### `synthesize`
33
+
34
+ Produce a `ProposedPolicy` from a recording or from a mandate file (exactly
35
+ one of the two):
93
36
 
94
37
  ```sh
95
- policy-builder synthesize \
96
- --recorded-tx recorded-tx.json \
97
- --network mainnet \
98
- --responses responses.json \
99
- --smart-account CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIT4C \
100
- --install-nonce 1 \
101
- --json
38
+ policy-builder synthesize --network mainnet --recorded-tx recorded.json
39
+ policy-builder synthesize --mandate mandate.json
102
40
  ```
103
41
 
104
- Synthesise deterministically from a mandate (`mandate.json`):
42
+ `synthesize --recorded-tx` accepts either a bare `RecordedTransaction` or the
43
+ artifact `record --out` writes, so `record --out tx.json` followed by
44
+ `synthesize --recorded-tx tx.json` works end to end. Add `--explain` to
45
+ include the human-readable review card and predicate tree in the output.
105
46
 
106
- ```json
107
- {
108
- "chain": "stellar",
109
- "contract": "CTOKEN",
110
- "method": "transfer",
111
- "spendingLimit": { "token": "CTOKEN", "limit": "5000000", "windowSeconds": 2592000 }
112
- }
113
- ```
47
+ Both commands print a single JSON envelope to stdout: the result on success,
48
+ a machine-readable error object (stable `code`, `severity`, `retryable`) on
49
+ failure. That makes the output safe to pipe and to assert on in CI.
114
50
 
115
- ```sh
116
- policy-builder synthesize --mandate mandate.json --json
117
- ```
51
+ ## Beyond record and synthesize
118
52
 
119
- > Recording an XDR envelope (`--xdr`) instead of an on-chain `--hash` has no raw
120
- > events to cross-check, so the recorder fails closed at the default confidence
121
- > threshold. See [`@crediolabs/policy-synth`](https://www.npmjs.com/package/@crediolabs/policy-synth)
122
- > for the confidence-override details.
53
+ Verification, simulation, install and revoke are part of the same core; drive
54
+ them from
55
+ [`@crediolabs/policy-builder-mcp`](https://www.npmjs.com/package/@crediolabs/policy-builder-mcp)
56
+ or call `@crediolabs/policy-synth/run` directly. The CLI stays deliberately
57
+ small: the two commands that fit a pipe.
123
58
 
124
- ## Status
59
+ ## Security model
125
60
 
126
- The `record` and `synthesize` commands (both the mandate and recording
127
- front-ends) are implemented and test-covered. Install, on-chain verify, and
128
- simulate are later phases.
61
+ The CLI holds no key material and signs nothing. See the
62
+ [architecture document](https://github.com/untangledfinance/octogate/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/octogate#readme) for
65
+ the contracts' audit status.
129
66
 
130
67
  ## License
131
68
 
132
- MIT.
69
+ MIT
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // apps/policy-builder-cli/bin/policy-builder.ts - subcommand router.
2
+ // packages/policy-builder-cli/bin/policy-builder.ts - subcommand router.
3
3
  //
4
4
  // Usage:
5
5
  // policy-builder record --network <mainnet|testnet> --hash <tx>
@@ -26,6 +26,7 @@
26
26
  // `command + subcommand-flags + global-flags` and dispatches to the matching
27
27
  // command body. Bad args -> CliError -> non-zero exit (JSON envelope under
28
28
  // --json).
29
+ import { runMergeCommand } from "../src/commands/merge.js";
29
30
  import { runRecordCommand } from "../src/commands/record.js";
30
31
  import { runSynthesizeCommand } from "../src/commands/synthesize.js";
31
32
  import { emitCliError, parseFlags } from "../src/output.js";
@@ -47,6 +48,10 @@ async function main() {
47
48
  await runSynthesizeCommand(subcommandArgs, flags);
48
49
  return;
49
50
  }
51
+ case 'merge': {
52
+ await runMergeCommand(subcommandArgs, flags);
53
+ return;
54
+ }
50
55
  case 'help':
51
56
  case '--help':
52
57
  case '-h':
@@ -68,6 +73,10 @@ function printHelp() {
68
73
 
69
74
  Usage:
70
75
  policy-builder record --network <mainnet|testnet> --hash <tx> | --xdr <b64>
76
+ policy-builder merge --smart-account <C...> --source-account <G...>
77
+ --rule-id <n> --predicate <b64> --step <detach|reinstall>
78
+ [--network <mainnet|testnet>] [--rpc-url <url>]
79
+ [--json] [--quiet] [--out <path>]
71
80
  [--json] [--quiet] [--out <path>]
72
81
  policy-builder synthesize --mandate <path.json>
73
82
  [--oz-config <path.json>] [--confidence <0..1>]
@@ -0,0 +1,2 @@
1
+ import { type CliFlags } from '../output.ts';
2
+ export declare function runMergeCommand(argv: ReadonlyArray<string>, flags: CliFlags): Promise<import("@crediolabs/policy-synth/run").MergePolicyResult>;
@@ -0,0 +1,58 @@
1
+ // packages/policy-builder-cli/src/commands/merge.ts
2
+ //
3
+ // `policy-builder merge` subcommand. The tightening remedy for a cross-rule
4
+ // authority overlap.
5
+ //
6
+ // OpenZeppelin enforces only the policies of the context rule a signer NAMES,
7
+ // so a signer in several rules picks which one applies and their authority is
8
+ // the maximum over the matching rules. Adding a second, tighter rule therefore
9
+ // restricts nothing. To actually tighten, the restriction has to become ONE
10
+ // predicate: the conjunction of the installed one and the new one.
11
+ //
12
+ // Two transactions, in order, because `add_policy` refuses a policy already
13
+ // attached to the rule:
14
+ //
15
+ // policy-builder merge --step detach ... sign and submit, wait
16
+ // policy-builder merge --step reinstall ... sign and submit
17
+ //
18
+ // The detach uninstalls the policy, which resets every counter on the rule and
19
+ // leaves it unpoliced until the reinstall confirms. Both are reported as
20
+ // warnings rather than left for the operator to discover afterwards.
21
+ import { runMergePolicy } from '@crediolabs/policy-synth/run';
22
+ import { CliError, formatToolResponse, parsePairs } from "../output.js";
23
+ function missing(message) {
24
+ return new CliError({
25
+ code: 'CLI_MISSING_ARG',
26
+ message,
27
+ severity: 'error',
28
+ retryable: false,
29
+ });
30
+ }
31
+ function required(pairs, name) {
32
+ const value = pairs[name];
33
+ if (!value)
34
+ throw missing(`merge: --${name} is required`);
35
+ return value;
36
+ }
37
+ export async function runMergeCommand(argv, flags) {
38
+ const pairs = parsePairs(argv);
39
+ const step = required(pairs, 'step');
40
+ if (step !== 'detach' && step !== 'reinstall') {
41
+ throw missing("merge: --step must be 'detach' or 'reinstall'");
42
+ }
43
+ const ruleId = Number(required(pairs, 'rule-id'));
44
+ if (!Number.isInteger(ruleId) || ruleId < 0) {
45
+ throw missing('merge: --rule-id must be a non-negative integer');
46
+ }
47
+ const res = await runMergePolicy({
48
+ smartAccount: required(pairs, 'smart-account'),
49
+ sourceAccount: required(pairs, 'source-account'),
50
+ ruleId,
51
+ incomingPredicateBlobBase64: required(pairs, 'predicate'),
52
+ step,
53
+ ...(pairs.network ? { network: pairs.network } : {}),
54
+ ...(pairs['rpc-url'] ? { rpcUrl: pairs['rpc-url'] } : {}),
55
+ ...(pairs['allow-unpinned-rpc-url'] === 'true' ? { allowUnpinnedRpcUrl: true } : {}),
56
+ });
57
+ return formatToolResponse(res, flags);
58
+ }
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-cli/src/commands/record.ts
1
+ // packages/policy-builder-cli/src/commands/record.ts
2
2
  //
3
3
  // `policy-builder record` subcommand. Thin wrapper around the core
4
4
  // `recordTransaction` - no business logic, just argv -> RecordInput + the
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-cli/src/commands/synthesize.ts
1
+ // packages/policy-builder-cli/src/commands/synthesize.ts
2
2
  //
3
3
  // `policy-builder synthesize` subcommand. Dispatches to ONE of the two
4
4
  // front-ends (mandate / recording) based on which file flag is supplied:
@@ -48,14 +48,7 @@ export async function runSynthesizeCommand(argv, flags) {
48
48
  if (hasMandate) {
49
49
  const mandate = readJsonFile(pairs.mandate);
50
50
  const args = { source: 'mandate', mandate };
51
- if (pairs['oz-config'] !== undefined) {
52
- args.ozConfig = readOzConfigFile(pairs['oz-config']);
53
- }
54
- if (pairs.confidence !== undefined) {
55
- args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
56
- }
57
- if (explain)
58
- args.explain = true;
51
+ applySharedFlags(args, pairs, explain);
59
52
  const res = await runSynthesizePolicy(args);
60
53
  if (explain)
61
54
  emitExplainBlock(res, flags);
@@ -84,10 +77,7 @@ export async function runSynthesizeCommand(argv, flags) {
84
77
  const userResponses = {};
85
78
  if (pairs.responses) {
86
79
  const file = readJsonFile(pairs.responses);
87
- if (file !== null && typeof file === 'object' && !Array.isArray(file)) {
88
- Object.assign(userResponses, file);
89
- }
90
- else {
80
+ if (file === null || typeof file !== 'object' || Array.isArray(file)) {
91
81
  throw new CliError({
92
82
  code: 'CLI_INVALID_JSON',
93
83
  message: `synthesize: --responses ${pairs.responses} must be a JSON object`,
@@ -95,48 +85,43 @@ export async function runSynthesizeCommand(argv, flags) {
95
85
  retryable: false,
96
86
  });
97
87
  }
88
+ Object.assign(userResponses, file);
98
89
  }
99
- if (pairs['window-seconds'] !== undefined) {
100
- userResponses.windowSeconds = parsePositiveInt(pairs['window-seconds'], '--window-seconds');
101
- }
102
- if (pairs['valid-until'] !== undefined) {
103
- userResponses.validUntilLedger = parsePositiveInt(pairs['valid-until'], '--valid-until');
104
- }
105
- if (pairs['limit-amount'] !== undefined) {
106
- userResponses.limitAmount = parseI128String(pairs['limit-amount'], '--limit-amount');
107
- }
108
- if (pairs['invocation-limit'] !== undefined) {
109
- userResponses.invocationLimit = parsePositiveInt(pairs['invocation-limit'], '--invocation-limit');
90
+ // Per-field overrides. Each entry: argv flag name, userResponses key,
91
+ // and a parser that validates the raw string.
92
+ const userResponseFlags = [
93
+ ['window-seconds', 'windowSeconds', parsePositiveInt],
94
+ ['valid-until', 'validUntilLedger', parsePositiveInt],
95
+ ['limit-amount', 'limitAmount', parseI128String],
96
+ ['invocation-limit', 'invocationLimit', parsePositiveInt],
97
+ ];
98
+ for (const [flag, key, parse] of userResponseFlags) {
99
+ if (pairs[flag] !== undefined) {
100
+ userResponses[key] = parse(pairs[flag], `--${flag}`);
101
+ }
110
102
  }
111
103
  // --recipient <C...|G...> is REPEATABLE (parsePairs collapses duplicate keys,
112
104
  // so it is collected straight from argv). Each value builds the swap-recipient
113
105
  // allowlist; supplying it REPLACES the default pin to the recorded recipient.
114
106
  const recipients = collectRepeated(argv, 'recipient');
115
- if (recipients.length > 0) {
116
- for (const r of recipients) {
117
- // Same validator the run-layer schema applies (SDK StrKey underneath) - a
118
- // swap recipient may be a G... wallet or a C... contract. Shared rather
119
- // than re-inlined so the CLI and the schema cannot drift apart.
120
- if (!isStellarAddress(r)) {
121
- throw new CliError({
122
- code: 'CLI_MISSING_ARG',
123
- message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
124
- severity: 'error',
125
- retryable: false,
126
- });
127
- }
107
+ for (const r of recipients) {
108
+ // Same validator the run-layer schema applies (SDK StrKey underneath) - a
109
+ // swap recipient may be a G... wallet or a C... contract. Shared rather
110
+ // than re-inlined so the CLI and the schema cannot drift apart.
111
+ if (!isStellarAddress(r)) {
112
+ throw new CliError({
113
+ code: 'CLI_MISSING_ARG',
114
+ message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
115
+ severity: 'error',
116
+ retryable: false,
117
+ });
128
118
  }
129
- userResponses.swapRecipientAllowlist = recipients;
130
119
  }
131
- if (Object.keys(userResponses).length > 0) {
120
+ if (recipients.length > 0)
121
+ userResponses.swapRecipientAllowlist = recipients;
122
+ if (Object.keys(userResponses).length > 0)
132
123
  args.userResponses = userResponses;
133
- }
134
- if (pairs['oz-config'] !== undefined) {
135
- args.ozConfig = readOzConfigFile(pairs['oz-config']);
136
- }
137
- if (pairs.confidence !== undefined) {
138
- args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
139
- }
124
+ applySharedFlags(args, pairs, explain);
140
125
  // --smart-account <C...> opts into the interpreter adapter, so constraints OZ
141
126
  // cannot express (per-method scoping, invocation-count windows, oracle bounds,
142
127
  // exact hop paths) lower to a real predicate document instead of just warnings.
@@ -146,31 +131,32 @@ export async function runSynthesizeCommand(argv, flags) {
146
131
  // without `--smart-account` are rejected up front instead of being silently
147
132
  // dropped. The foot-gun: an empty value previously produced an "ok" envelope
148
133
  // with 0 policyDocuments, so callers thought the constraint had been enforced
149
- // when in fact it had been silently skipped.
134
+ // when it had been silently skipped.
150
135
  const smartAccountRaw = pairs['smart-account'];
151
136
  const installNonceRaw = pairs['install-nonce'];
152
137
  const oracleStalenessRaw = pairs['oracle-max-staleness'];
153
138
  const oracleDeviationRaw = pairs['oracle-max-deviation'];
154
- if (installNonceRaw !== undefined && smartAccountRaw === undefined) {
155
- throw new CliError({
156
- code: 'CLI_MISSING_ARG',
157
- message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
158
- severity: 'error',
159
- retryable: false,
160
- });
161
- }
162
- // Oracle params are an interpreter-only knob; reject up front so they cannot
163
- // be silently dropped when --smart-account is absent.
164
- if ((oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) &&
165
- smartAccountRaw === undefined) {
166
- throw new CliError({
167
- code: 'CLI_MISSING_ARG',
168
- message: 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
169
- severity: 'error',
170
- retryable: false,
171
- });
139
+ // --install-nonce and the oracle knobs are interpreter-only. Reject up
140
+ // front so they cannot be silently dropped when --smart-account is absent.
141
+ if (smartAccountRaw === undefined) {
142
+ if (installNonceRaw !== undefined) {
143
+ throw new CliError({
144
+ code: 'CLI_MISSING_ARG',
145
+ message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
146
+ severity: 'error',
147
+ retryable: false,
148
+ });
149
+ }
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
+ }
172
158
  }
173
- if (smartAccountRaw !== undefined) {
159
+ else {
174
160
  const smartAccount = smartAccountRaw.trim();
175
161
  if (smartAccount.length === 0) {
176
162
  throw new CliError({
@@ -227,6 +213,19 @@ export async function runSynthesizeCommand(argv, flags) {
227
213
  emitExplainBlock(res, flags);
228
214
  return formatToolResponse(res, flags, 'synthesize(recording)');
229
215
  }
216
+ /** Apply flags shared between the mandate and recording synthesize paths:
217
+ * --oz-config, --confidence, --explain. Pulled out so adding a shared flag
218
+ * adds one branch here rather than two copies in the command body. */
219
+ function applySharedFlags(args, pairs, explain) {
220
+ if (pairs['oz-config'] !== undefined) {
221
+ args.ozConfig = readOzConfigFile(pairs['oz-config']);
222
+ }
223
+ if (pairs.confidence !== undefined) {
224
+ args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
225
+ }
226
+ if (explain)
227
+ args.explain = true;
228
+ }
230
229
  /** Augment the tool response envelope with the --explain fields and
231
230
  * (in non-JSON mode) print the review card readably. The CLI is the
232
231
  * single seam that places `review` + `predicateTree` on the wire
@@ -301,9 +300,11 @@ function parseConfidence(raw) {
301
300
  }
302
301
  /** Parse a strictly positive integer (windowSeconds, validUntilLedger,
303
302
  * invocationLimit, oracleParams bounds). The core re-validates these with
304
- * field-specific caps; the CLI just enforces "looks like an integer > 0". */
303
+ * field-specific caps; the CLI just enforces "looks like an integer > 0".
304
+ * The `^[0-9]+$` regex already pins the shape to a non-negative integer, so
305
+ * the only thing left to check is "not zero". */
305
306
  function parsePositiveInt(raw, flagName) {
306
- if (!POSITIVE_INT_RE.test(raw)) {
307
+ if (!POSITIVE_INT_RE.test(raw) || raw === '0') {
307
308
  throw new CliError({
308
309
  code: 'CLI_MISSING_ARG',
309
310
  message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
@@ -311,16 +312,7 @@ function parsePositiveInt(raw, flagName) {
311
312
  retryable: false,
312
313
  });
313
314
  }
314
- const n = Number(raw);
315
- if (!Number.isInteger(n) || n <= 0) {
316
- throw new CliError({
317
- code: 'CLI_MISSING_ARG',
318
- message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
319
- severity: 'error',
320
- retryable: false,
321
- });
322
- }
323
- return n;
315
+ return Number(raw);
324
316
  }
325
317
  /** Collect ALL values for a repeatable `--<name> <value>` / `--<name>=<value>`
326
318
  * flag from argv, in order. Unlike `parsePairs` (which keeps only the last
@@ -1,3 +1,4 @@
1
+ export { runMergeCommand } from './commands/merge.ts';
1
2
  export { runRecordCommand } from './commands/record.ts';
2
3
  export { runSynthesizeCommand } from './commands/synthesize.ts';
3
4
  export { formatToolResponse, readJsonFile, writeJsonFile } from './output.ts';
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
- // apps/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
1
+ // packages/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
2
+ export { runMergeCommand } from "./commands/merge.js";
2
3
  export { runRecordCommand } from "./commands/record.js";
3
4
  export { runSynthesizeCommand } from "./commands/synthesize.js";
4
5
  export { formatToolResponse, readJsonFile, writeJsonFile } from "./output.js";
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-cli/src/output.ts
1
+ // packages/policy-builder-cli/src/output.ts
2
2
  //
3
3
  // Output helpers for the CLI: formatToolResponse for the `--json` flag and
4
4
  // file I/O for `--out`. The CLI is intentionally tiny - no commander / yargs
@@ -110,20 +110,19 @@ export function writeJsonFile(path, value) {
110
110
  * Throws CliError so the router can map it to a process exit code + a
111
111
  * structured JSON envelope under --json. */
112
112
  export function formatToolResponse(res, flags, outLabel = 'result') {
113
- if (res.ok) {
114
- const envelope = { ok: true, data: res.data };
115
- if (flags.out)
116
- writeJsonFile(flags.out, envelope);
117
- if (flags.json) {
118
- // newline-terminated JSON so it pipes cleanly
119
- process.stdout.write(`${JSON.stringify(envelope)}\n`);
120
- }
121
- else if (!flags.quiet) {
122
- process.stdout.write(`${outLabel}: ok\n`);
123
- }
124
- return res.data;
113
+ if (!res.ok)
114
+ throw new CliError(res.error);
115
+ const envelope = { ok: true, data: res.data };
116
+ if (flags.out)
117
+ writeJsonFile(flags.out, envelope);
118
+ if (flags.json) {
119
+ // newline-terminated JSON so it pipes cleanly
120
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
121
+ }
122
+ else if (!flags.quiet) {
123
+ process.stdout.write(`${outLabel}: ok\n`);
125
124
  }
126
- throw new CliError(res.error);
125
+ return res.data;
127
126
  }
128
127
  /** CLI-local error class wrapping a (core or CLI) ToolError so the router can
129
128
  * map it to a non-zero exit. The error is preserved verbatim for --json. */
@@ -0,0 +1,2 @@
1
+ import { type CliFlags } from '../output.ts';
2
+ export declare function runMergeCommand(argv: ReadonlyArray<string>, flags: CliFlags): Promise<import("@crediolabs/policy-synth/run").MergePolicyResult>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ // packages/policy-builder-cli/src/commands/merge.ts
3
+ //
4
+ // `policy-builder merge` subcommand. The tightening remedy for a cross-rule
5
+ // authority overlap.
6
+ //
7
+ // OpenZeppelin enforces only the policies of the context rule a signer NAMES,
8
+ // so a signer in several rules picks which one applies and their authority is
9
+ // the maximum over the matching rules. Adding a second, tighter rule therefore
10
+ // restricts nothing. To actually tighten, the restriction has to become ONE
11
+ // predicate: the conjunction of the installed one and the new one.
12
+ //
13
+ // Two transactions, in order, because `add_policy` refuses a policy already
14
+ // attached to the rule:
15
+ //
16
+ // policy-builder merge --step detach ... sign and submit, wait
17
+ // policy-builder merge --step reinstall ... sign and submit
18
+ //
19
+ // The detach uninstalls the policy, which resets every counter on the rule and
20
+ // leaves it unpoliced until the reinstall confirms. Both are reported as
21
+ // warnings rather than left for the operator to discover afterwards.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.runMergeCommand = runMergeCommand;
24
+ const run_1 = require("@crediolabs/policy-synth/run");
25
+ const output_ts_1 = require("../output.js");
26
+ function missing(message) {
27
+ return new output_ts_1.CliError({
28
+ code: 'CLI_MISSING_ARG',
29
+ message,
30
+ severity: 'error',
31
+ retryable: false,
32
+ });
33
+ }
34
+ function required(pairs, name) {
35
+ const value = pairs[name];
36
+ if (!value)
37
+ throw missing(`merge: --${name} is required`);
38
+ return value;
39
+ }
40
+ async function runMergeCommand(argv, flags) {
41
+ const pairs = (0, output_ts_1.parsePairs)(argv);
42
+ const step = required(pairs, 'step');
43
+ if (step !== 'detach' && step !== 'reinstall') {
44
+ throw missing("merge: --step must be 'detach' or 'reinstall'");
45
+ }
46
+ const ruleId = Number(required(pairs, 'rule-id'));
47
+ if (!Number.isInteger(ruleId) || ruleId < 0) {
48
+ throw missing('merge: --rule-id must be a non-negative integer');
49
+ }
50
+ const res = await (0, run_1.runMergePolicy)({
51
+ smartAccount: required(pairs, 'smart-account'),
52
+ sourceAccount: required(pairs, 'source-account'),
53
+ ruleId,
54
+ incomingPredicateBlobBase64: required(pairs, 'predicate'),
55
+ step,
56
+ ...(pairs.network ? { network: pairs.network } : {}),
57
+ ...(pairs['rpc-url'] ? { rpcUrl: pairs['rpc-url'] } : {}),
58
+ ...(pairs['allow-unpinned-rpc-url'] === 'true' ? { allowUnpinnedRpcUrl: true } : {}),
59
+ });
60
+ return (0, output_ts_1.formatToolResponse)(res, flags);
61
+ }
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-cli/src/commands/record.ts
2
+ // packages/policy-builder-cli/src/commands/record.ts
3
3
  //
4
4
  // `policy-builder record` subcommand. Thin wrapper around the core
5
5
  // `recordTransaction` - no business logic, just argv -> RecordInput + the