@crediolabs/policy-builder-cli 0.1.6 → 0.1.8

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.
@@ -7,11 +7,23 @@
7
7
  //
8
8
  // The CLI mirrors the MCP tool's discriminated union: one subcommand, two
9
9
  // front-ends, mutually exclusive.
10
+ //
11
+ // Per-field response flags (--window-seconds, --valid-until, --limit-amount,
12
+ // --invocation-limit) merge into `userResponses`. A flag overrides the same
13
+ // 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
+ // the interpreter opt-in and are rejected without --smart-account; tighten-only
16
+ // bounds are validated by the core.
10
17
 
11
- import { runSynthesizePolicy } from '@crediolabs/policy-builder-mcp'
12
- import type { ProposedPolicy } from '@crediolabs/policy-synth'
18
+ import { isStellarAddress, type ProposedPolicy } from '@crediolabs/policy-synth'
19
+ import { runSynthesizePolicy } from '@crediolabs/policy-synth/run'
13
20
  import { CliError, type CliFlags, formatToolResponse, parsePairs, readJsonFile } from '../output.ts'
14
21
 
22
+ // Positive-int flags and i128 amount strings share the same wire shape: a
23
+ // base-10 unsigned decimal, no sign. The i128 stays a string at the boundary
24
+ // because it is wider than Number.MAX_SAFE_INTEGER.
25
+ const POSITIVE_INT_RE = /^[0-9]+$/
26
+
15
27
  export async function runSynthesizeCommand(
16
28
  argv: ReadonlyArray<string>,
17
29
  flags: CliFlags
@@ -30,7 +42,14 @@ export async function runSynthesizeCommand(
30
42
 
31
43
  if (hasMandate) {
32
44
  const mandate = readJsonFile(pairs.mandate as string) as Record<string, unknown>
33
- const res = await runSynthesizePolicy({ source: 'mandate', mandate })
45
+ const args: Record<string, unknown> = { source: 'mandate', mandate }
46
+ if (pairs['oz-config'] !== undefined) {
47
+ args.ozConfig = readOzConfigFile(pairs['oz-config'] as string)
48
+ }
49
+ if (pairs.confidence !== undefined) {
50
+ args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
51
+ }
52
+ const res = await runSynthesizePolicy(args)
34
53
  return formatToolResponse(res, flags, 'synthesize(mandate)')
35
54
  }
36
55
 
@@ -53,20 +72,257 @@ export async function runSynthesizeCommand(
53
72
  })
54
73
  }
55
74
  const args: Record<string, unknown> = { source: 'recording', recordedTx, network }
75
+
76
+ // userResponses precedence: --responses file is the base; per-field flags
77
+ // override the same field. Only the override'd fields are merged in.
78
+ const userResponses: Record<string, unknown> = {}
56
79
  if (pairs.responses) {
57
- args.userResponses = readJsonFile(pairs.responses)
80
+ const file = readJsonFile(pairs.responses)
81
+ if (file !== null && typeof file === 'object' && !Array.isArray(file)) {
82
+ Object.assign(userResponses, file as Record<string, unknown>)
83
+ } else {
84
+ throw new CliError({
85
+ code: 'CLI_INVALID_JSON',
86
+ message: `synthesize: --responses ${pairs.responses} must be a JSON object`,
87
+ severity: 'error',
88
+ retryable: false,
89
+ })
90
+ }
91
+ }
92
+ if (pairs['window-seconds'] !== undefined) {
93
+ userResponses.windowSeconds = parsePositiveInt(
94
+ pairs['window-seconds'] as string,
95
+ '--window-seconds'
96
+ )
97
+ }
98
+ if (pairs['valid-until'] !== undefined) {
99
+ userResponses.validUntilLedger = parsePositiveInt(
100
+ pairs['valid-until'] as string,
101
+ '--valid-until'
102
+ )
103
+ }
104
+ if (pairs['limit-amount'] !== undefined) {
105
+ userResponses.limitAmount = parseI128String(pairs['limit-amount'] as string, '--limit-amount')
106
+ }
107
+ if (pairs['invocation-limit'] !== undefined) {
108
+ userResponses.invocationLimit = parsePositiveInt(
109
+ pairs['invocation-limit'] as string,
110
+ '--invocation-limit'
111
+ )
58
112
  }
113
+ // --recipient <C...|G...> is REPEATABLE (parsePairs collapses duplicate keys,
114
+ // so it is collected straight from argv). Each value builds the swap-recipient
115
+ // allowlist; supplying it REPLACES the default pin to the recorded recipient.
116
+ const recipients = collectRepeated(argv, 'recipient')
117
+ if (recipients.length > 0) {
118
+ for (const r of recipients) {
119
+ // Same validator the run-layer schema applies (SDK StrKey underneath) - a
120
+ // swap recipient may be a G... wallet or a C... contract. Shared rather
121
+ // than re-inlined so the CLI and the schema cannot drift apart.
122
+ if (!isStellarAddress(r)) {
123
+ throw new CliError({
124
+ code: 'CLI_MISSING_ARG',
125
+ message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
126
+ severity: 'error',
127
+ retryable: false,
128
+ })
129
+ }
130
+ }
131
+ userResponses.swapRecipientAllowlist = recipients
132
+ }
133
+ if (Object.keys(userResponses).length > 0) {
134
+ args.userResponses = userResponses
135
+ }
136
+
137
+ if (pairs['oz-config'] !== undefined) {
138
+ args.ozConfig = readOzConfigFile(pairs['oz-config'] as string)
139
+ }
140
+ if (pairs.confidence !== undefined) {
141
+ args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
142
+ }
143
+
59
144
  // --smart-account <C...> opts into the interpreter adapter, so constraints OZ
60
145
  // cannot express (per-method scoping, invocation-count windows, oracle bounds,
61
146
  // exact hop paths) lower to a real predicate document instead of just warnings.
62
147
  // The core validates the address and installNonce; a bad value surfaces there.
63
- if (pairs['smart-account']) {
64
- const interpreter: Record<string, unknown> = { smartAccountAddress: pairs['smart-account'] }
65
- if (pairs['install-nonce']) {
66
- interpreter.installNonce = Number(pairs['install-nonce'])
148
+ //
149
+ // Use `!== undefined` (not truthy) so `--smart-account ""` and `--install-nonce`
150
+ // without `--smart-account` are rejected up front instead of being silently
151
+ // dropped. The foot-gun: an empty value previously produced an "ok" envelope
152
+ // with 0 policyDocuments, so callers thought the constraint had been enforced
153
+ // when in fact it had been silently skipped.
154
+ const smartAccountRaw = pairs['smart-account']
155
+ const installNonceRaw = pairs['install-nonce']
156
+ const oracleStalenessRaw = pairs['oracle-max-staleness']
157
+ const oracleDeviationRaw = pairs['oracle-max-deviation']
158
+ if (installNonceRaw !== undefined && smartAccountRaw === undefined) {
159
+ throw new CliError({
160
+ code: 'CLI_MISSING_ARG',
161
+ message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
162
+ severity: 'error',
163
+ retryable: false,
164
+ })
165
+ }
166
+ // Oracle params are an interpreter-only knob; reject up front so they cannot
167
+ // be silently dropped when --smart-account is absent.
168
+ if (
169
+ (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) &&
170
+ smartAccountRaw === undefined
171
+ ) {
172
+ throw new CliError({
173
+ code: 'CLI_MISSING_ARG',
174
+ message:
175
+ 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
176
+ severity: 'error',
177
+ retryable: false,
178
+ })
179
+ }
180
+ if (smartAccountRaw !== undefined) {
181
+ const smartAccount = smartAccountRaw.trim()
182
+ if (smartAccount.length === 0) {
183
+ throw new CliError({
184
+ code: 'CLI_MISSING_ARG',
185
+ message:
186
+ 'synthesize: --smart-account <C...> was passed empty; provide a 56-character contract strkey or omit the flag',
187
+ severity: 'error',
188
+ retryable: false,
189
+ })
190
+ }
191
+ if (!/^C[2-7A-Z]{55}$/.test(smartAccount)) {
192
+ throw new CliError({
193
+ code: 'CLI_MISSING_ARG',
194
+ message: `synthesize: --smart-account "${smartAccount}" is not a valid C... contract strkey (expected 56 chars starting with C)`,
195
+ severity: 'error',
196
+ retryable: false,
197
+ })
198
+ }
199
+ const interpreter: Record<string, unknown> = { smartAccountAddress: smartAccount }
200
+ if (installNonceRaw !== undefined) {
201
+ const nonce = Number(installNonceRaw)
202
+ if (!Number.isInteger(nonce) || nonce < 0) {
203
+ throw new CliError({
204
+ code: 'CLI_MISSING_ARG',
205
+ message: `synthesize: --install-nonce "${installNonceRaw}" is not a non-negative integer`,
206
+ severity: 'error',
207
+ retryable: false,
208
+ })
209
+ }
210
+ interpreter.installNonce = nonce
211
+ }
212
+ // Oracle params only attach when at least one bound was provided. The
213
+ // core validates tighten-only (maxStalenessSeconds <= 600,
214
+ // maxDeviationBps <= 200) - a too-loose value surfaces as SYNTHESIS_ERROR.
215
+ if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
216
+ const oracleParams: Record<string, number> = {}
217
+ if (oracleStalenessRaw !== undefined) {
218
+ oracleParams.maxStalenessSeconds = parsePositiveInt(
219
+ oracleStalenessRaw,
220
+ '--oracle-max-staleness'
221
+ )
222
+ }
223
+ if (oracleDeviationRaw !== undefined) {
224
+ oracleParams.maxDeviationBps = parsePositiveInt(
225
+ oracleDeviationRaw,
226
+ '--oracle-max-deviation'
227
+ )
228
+ }
229
+ interpreter.oracleParams = oracleParams
67
230
  }
68
231
  args.interpreter = interpreter
69
232
  }
70
233
  const res = await runSynthesizePolicy(args)
71
234
  return formatToolResponse(res, flags, 'synthesize(recording)')
72
235
  }
236
+
237
+ /** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
238
+ * CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
239
+ * on `ozConfig` catches shape mismatches downstream. */
240
+ function readOzConfigFile(path: string): Record<string, unknown> {
241
+ const value = readJsonFile(path)
242
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
243
+ throw new CliError({
244
+ code: 'CLI_INVALID_JSON',
245
+ message: `synthesize: --oz-config ${path} must be a JSON object`,
246
+ severity: 'error',
247
+ retryable: false,
248
+ })
249
+ }
250
+ return value as Record<string, unknown>
251
+ }
252
+
253
+ /** Parse and validate `--confidence <n>` as a finite number in [0, 1]. A
254
+ * threshold above 1 would disable the recorder gate; reject it up front. */
255
+ function parseConfidence(raw: string): number {
256
+ const n = Number(raw)
257
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
258
+ throw new CliError({
259
+ code: 'CLI_MISSING_ARG',
260
+ message: `synthesize: --confidence "${raw}" must be a finite number within [0, 1]`,
261
+ severity: 'error',
262
+ retryable: false,
263
+ })
264
+ }
265
+ return n
266
+ }
267
+
268
+ /** Parse a strictly positive integer (windowSeconds, validUntilLedger,
269
+ * invocationLimit, oracleParams bounds). The core re-validates these with
270
+ * field-specific caps; the CLI just enforces "looks like an integer > 0". */
271
+ function parsePositiveInt(raw: string, flagName: string): number {
272
+ if (!POSITIVE_INT_RE.test(raw)) {
273
+ throw new CliError({
274
+ code: 'CLI_MISSING_ARG',
275
+ message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
276
+ severity: 'error',
277
+ retryable: false,
278
+ })
279
+ }
280
+ const n = Number(raw)
281
+ if (!Number.isInteger(n) || n <= 0) {
282
+ throw new CliError({
283
+ code: 'CLI_MISSING_ARG',
284
+ message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
285
+ severity: 'error',
286
+ retryable: false,
287
+ })
288
+ }
289
+ return n
290
+ }
291
+
292
+ /** Collect ALL values for a repeatable `--<name> <value>` / `--<name>=<value>`
293
+ * flag from argv, in order. Unlike `parsePairs` (which keeps only the last
294
+ * occurrence of a key), this preserves every occurrence so a flag like
295
+ * `--recipient` can be supplied multiple times to build an allowlist. A
296
+ * trailing `--<name>` with no value (or another flag next) is skipped. */
297
+ function collectRepeated(argv: ReadonlyArray<string>, name: string): string[] {
298
+ const out: string[] = []
299
+ const eqPrefix = `--${name}=`
300
+ for (let i = 0; i < argv.length; i++) {
301
+ const a = argv[i]
302
+ if (a === `--${name}`) {
303
+ const next = argv[i + 1]
304
+ if (next !== undefined && !next.startsWith('--')) {
305
+ out.push(next)
306
+ i++
307
+ }
308
+ } else if (a?.startsWith(eqPrefix)) {
309
+ out.push(a.slice(eqPrefix.length))
310
+ }
311
+ }
312
+ return out
313
+ }
314
+
315
+ /** Parse an i128 decimal string (positive, base 10). The synth gate, not the
316
+ * CLI, decides what to do with negatives - real recordings carry positive
317
+ * amounts on the wire for `limitAmount`. */
318
+ function parseI128String(raw: string, flagName: string): string {
319
+ if (!POSITIVE_INT_RE.test(raw)) {
320
+ throw new CliError({
321
+ code: 'CLI_MISSING_ARG',
322
+ message: `synthesize: ${flagName} "${raw}" must be a positive decimal integer string (base-10 i128)`,
323
+ severity: 'error',
324
+ retryable: false,
325
+ })
326
+ }
327
+ return raw
328
+ }
package/src/output.ts CHANGED
@@ -37,7 +37,14 @@ export function parseFlags(argv: ReadonlyArray<string>): CliFlags {
37
37
 
38
38
  /** Resolve `--value <v>` style pairs after the subcommand name. Returns
39
39
  * an object keyed by the option name (without `--`). Throws on missing
40
- * value or duplicate keys. */
40
+ * value or duplicate keys.
41
+ *
42
+ * Note: an empty value (`--smart-account ""`) IS captured as an empty
43
+ * string so the caller can distinguish "flag omitted" from "flag passed
44
+ * empty" - a foot-gun: silently dropping empty values caused callers to
45
+ * believe the interpreter adapter was engaged when it was not. The next
46
+ * token is treated as a value iff it is present and does not start with
47
+ * `--`; tokens starting with `--` are never consumed as values. */
41
48
  export function parsePairs(argv: ReadonlyArray<string>): Record<string, string> {
42
49
  const out: Record<string, string> = {}
43
50
  for (let i = 0; i < argv.length; i++) {
@@ -47,11 +54,21 @@ export function parsePairs(argv: ReadonlyArray<string>): Record<string, string>
47
54
  const key = a.slice(2, eq)
48
55
  const val = a.slice(eq + 1)
49
56
  if (key && val !== undefined) out[key] = val
50
- } else if (a?.startsWith('--') && argv[i + 1] && !argv[i + 1]?.startsWith('--')) {
57
+ } else if (a?.startsWith('--')) {
51
58
  const key = a.slice(2)
52
- const val = argv[i + 1] as string
53
- out[key] = val
54
- i++
59
+ if (!key) continue
60
+ const next = argv[i + 1]
61
+ // Only consume the next token if it is present AND does not look like
62
+ // another flag. Empty strings DO count as values so callers can
63
+ // distinguish "omitted" from "passed empty".
64
+ if (next !== undefined && !next.startsWith('--')) {
65
+ out[key] = next
66
+ i++
67
+ } else {
68
+ // Standalone flag (no value) - record as empty string so `!== undefined`
69
+ // checks upstream can detect presence.
70
+ out[key] = ''
71
+ }
55
72
  }
56
73
  }
57
74
  return out