@nimbus-dev/connectors 0.2.2 → 0.2.3

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.
@@ -14,7 +14,13 @@
14
14
  *
15
15
  * The argument guard is the reason this is worth sharing rather than tolerating:
16
16
  * it is a security control, and a security control that exists in five
17
- * hand-written copies is one that can be strengthened in four of them.
17
+ * hand-written copies is one that can be strengthened in four of them. Every
18
+ * caller-supplied value that reaches a CLI's argv, in all eleven connectors
19
+ * that spawn one, now passes one of the argument schemas here — including in
20
+ * the connectors that spawn through `run-cli-json.ts` rather than
21
+ * {@link createCliJsonRunner}; bigquery's values never reach argv at all. And
22
+ * `scripts/cli-argument-guards.test.ts` fails a tool whose schema lets a value
23
+ * the CLI would misread through, whether or not something later refuses it.
18
24
  *
19
25
  * The `gcloud` connectors (bigquery, cloud-logging, gcp, vertex-ai) also share
20
26
  * how they hand gcloud its project and credentials: see {@link gcloudEnv} and
@@ -23,11 +29,29 @@
23
29
 
24
30
  import { z } from "zod";
25
31
  import { optionalEnv } from "./env-json-api.ts";
26
- import { isSafeCliArg } from "./safe-cli-arg.ts";
32
+ import {
33
+ awsCliArgProblem,
34
+ awsCliDocumentProblem,
35
+ azCliArgProblem,
36
+ cliArgProblem,
37
+ } from "./safe-cli-arg.ts";
27
38
 
28
39
  /** Body-snippet length in the thrown error. The value every connector used. */
29
40
  export const DEFAULT_STDERR_SNIPPET = 400;
30
41
 
42
+ /**
43
+ * A Zod string that refuses every value `problem` names a reason against, with that reason as the
44
+ * message — so a refused call says which rule it broke rather than listing all of them.
45
+ */
46
+ function cliArgSchema(problem: (value: unknown) => string | undefined): z.ZodString {
47
+ return z
48
+ .string()
49
+ .min(1)
50
+ .refine((value) => problem(value) === undefined, {
51
+ error: (issue) => problem(issue.input) ?? "is not a safe CLI argument",
52
+ });
53
+ }
54
+
31
55
  /**
32
56
  * A value passed to a CLI as an argument.
33
57
  *
@@ -35,10 +59,27 @@ export const DEFAULT_STDERR_SNIPPET = 400;
35
59
  * `-` would be read by the CLI as a flag rather than as the name it is meant to
36
60
  * be, and control characters have no legitimate place in one.
37
61
  */
38
- export const cliArg = z
39
- .string()
40
- .min(1)
41
- .refine(isSafeCliArg, { message: 'must not start with "-" or contain control characters' });
62
+ export const cliArg = cliArgSchema(cliArgProblem);
63
+
64
+ /**
65
+ * A value passed to the `aws` CLI as an argument: {@link cliArg}, and not a value the CLI would
66
+ * replace by reading a file or fetching a URL (`file://`, `fileb://`, `http://`, `https://`), nor
67
+ * one carrying the shorthand file-load operator `@=`.
68
+ */
69
+ export const awsCliArg = cliArgSchema(awsCliArgProblem);
70
+
71
+ /**
72
+ * A DOCUMENT passed to the `aws` CLI as one argument — a CloudFormation template body. Unlike
73
+ * {@link awsCliArg} it may span lines and run past 1024 characters; it still may not start with `-`
74
+ * or with a prefix that makes the CLI read it from somewhere else.
75
+ */
76
+ export const awsCliDocument = cliArgSchema(awsCliDocumentProblem);
77
+
78
+ /**
79
+ * A value passed to the `az` CLI as an argument: {@link cliArg}, and not one `az` would replace by
80
+ * a file's contents — a leading `@`, or `=@` anywhere.
81
+ */
82
+ export const azCliArg = cliArgSchema(azCliArgProblem);
42
83
 
43
84
  /** The spawn seam. Production passes `nimbusSpawn`; tests pass a fake. */
44
85
  export type SpawnFn = (
@@ -2,11 +2,9 @@ import type { McpListResult, ZodObjectSchema } from "@nimbus-dev/sdk/connector-k
2
2
 
3
3
  import { type AuditOutcome, appendAuditEntry } from "./audit-chain.ts";
4
4
  import { getConnectorMode } from "./connector-mode.ts";
5
+ import { createWriteBudget, parseWriteBudget, WRITE_BUDGET_ENV } from "./write-budget.ts";
5
6
  import { parseWriteScope, scopeAllows } from "./write-scope.ts";
6
7
 
7
- /** Mutations allowed per process lifetime when NIMBUS_MCP_WRITE_BUDGET is unset. */
8
- const DEFAULT_BUDGET = 10;
9
-
10
8
  /** The subset of the SDK's `RegisteredTool` this kit needs. */
11
9
  export type ToolHandle = { disable(): void };
12
10
 
@@ -84,6 +82,11 @@ function refused(why: string): McpListResult {
84
82
  return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: why }) }] };
85
83
  }
86
84
 
85
+ /** The message of whatever was thrown, which need not be an `Error`. */
86
+ function messageOf(e: unknown): string {
87
+ return e instanceof Error ? e.message : String(e);
88
+ }
89
+
87
90
  /**
88
91
  * Ask the human, through the client, and report whether they said yes.
89
92
  *
@@ -172,19 +175,25 @@ export function createWriteToolRegistrar(
172
175
 
173
176
  const scope = parseWriteScope(process.env[cfg.scopeEnv], cfg.scopeKinds);
174
177
  const auditLog = process.env["NIMBUS_MCP_AUDIT_LOG"];
175
- let remaining = Number(process.env["NIMBUS_MCP_WRITE_BUDGET"] ?? DEFAULT_BUDGET);
178
+ // Throws on a value that is not plain digits, an empty one included, as the scope parse above
179
+ // does on a malformed term: read with `Number()`, `abc` became NaN, which `remaining <= 0` never
180
+ // refused.
181
+ const budget = createWriteBudget(parseWriteBudget(process.env[WRITE_BUDGET_ENV]));
176
182
 
177
183
  async function record(
178
184
  tool: string,
179
185
  outcome: AuditOutcome,
180
186
  detail: Record<string, unknown>,
181
187
  ): Promise<void> {
182
- // Client-visible channel: any MCP client can display or persist this.
188
+ // Meant as a client-visible channel, but it reaches no client today: the SDK sends
189
+ // `notifications/message` only for a server that declared the MCP `logging` capability, and
190
+ // no connector declares it. The durable log below is the only record.
183
191
  await server.sendLoggingMessage({
184
192
  level: outcome === "executed" ? "info" : "warning",
185
193
  data: { connector: cfg.connector, tool, outcome },
186
194
  });
187
- // Durable channel: only when the operator configured a path.
195
+ // Durable channel: only when the operator configured a path. An append that fails rejects, and
196
+ // every record before the mutation is awaited, so a write whose entry did not land never runs.
188
197
  if (auditLog !== undefined && auditLog !== "") {
189
198
  await appendAuditEntry(auditLog, {
190
199
  ts: new Date().toISOString(),
@@ -196,6 +205,38 @@ export function createWriteToolRegistrar(
196
205
  }
197
206
  }
198
207
 
208
+ /**
209
+ * Take one unit of budget if any is left. Spending the last unit disables every write tool and
210
+ * tells the client.
211
+ *
212
+ * Nothing may be awaited between the check and the decrement, which is why both are the one
213
+ * synchronous call `budget.take()`. Calls run concurrently (the SDK starts each request's
214
+ * handler as it arrives), and with an await between the two every call in flight passed the
215
+ * check before any of them spent: three approved calls all mutated under a budget of one.
216
+ */
217
+ function spendOne(): boolean {
218
+ if (!budget.take()) return false;
219
+ if (!budget.hasLeft()) {
220
+ for (const h of handles) h.disable();
221
+ server.sendToolListChanged();
222
+ }
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * The refusal for a spent budget, worded the same for the client at both places it is checked.
228
+ * The audit reason tells them apart: `budget exhausted after approval` follows an `accepted`
229
+ * entry, because the budget ran out while that write's prompt was open.
230
+ */
231
+ async function budgetExhausted(
232
+ tool: string,
233
+ target: { kind: string; value: string },
234
+ reason: "budget exhausted" | "budget exhausted after approval",
235
+ ): Promise<McpListResult> {
236
+ await record(tool, "refused", { reason, target });
237
+ return refused("write budget exhausted for this session");
238
+ }
239
+
199
240
  /**
200
241
  * Decide the write surface, once, at the first moment client capabilities are knowable.
201
242
  *
@@ -269,11 +310,9 @@ export function createWriteToolRegistrar(
269
310
  return refused(`out of scope: ${target.kind}:${target.value} is not in ${cfg.scopeEnv}`);
270
311
  }
271
312
 
272
- // 2. BUDGET — same reasoning: a refusal that consent cannot lift comes before consent.
273
- if (remaining <= 0) {
274
- await record(name, "refused", { reason: "budget exhausted", target });
275
- return refused("write budget exhausted for this session");
276
- }
313
+ // 2. BUDGET — same reasoning: a refusal that consent cannot lift comes before consent. Not
314
+ // the last word on the budget, though: see step 4.
315
+ if (!budget.hasLeft()) return budgetExhausted(name, target, "budget exhausted");
277
316
 
278
317
  // 3. CONSENT.
279
318
  await record(name, "requested", { target, params: args });
@@ -283,7 +322,13 @@ export function createWriteToolRegistrar(
283
322
  }
284
323
  await record(name, "accepted", { target });
285
324
 
286
- // 4. PRE-STATE — after approval, before the mutation, so an unrecoverable action leaves a
325
+ // 4. SPEND — the budget checked AGAIN, and decremented in the same synchronous step: every
326
+ // call in flight passed step 2 before any of them got this far, so step 2 alone cannot
327
+ // hold the cap (see `spendOne`). Spent BEFORE the mutation so a throwing one still
328
+ // consumes budget — otherwise a failing destructive tool could be retried without limit.
329
+ if (!spendOne()) return budgetExhausted(name, target, "budget exhausted after approval");
330
+
331
+ // 5. PRE-STATE — after approval, before the mutation, so an unrecoverable action leaves a
287
332
  // record of what it destroyed. Capture failure is NOT fatal: refusing here would turn a
288
333
  // transient read error into a blocked action the owner already approved.
289
334
  let preState: Record<string, unknown> = {};
@@ -291,29 +336,39 @@ export function createWriteToolRegistrar(
291
336
  try {
292
337
  preState = await toolCfg.capturePreState(args);
293
338
  } catch (e) {
294
- preState = { captureFailed: e instanceof Error ? e.message : String(e) };
339
+ preState = { captureFailed: messageOf(e) };
295
340
  }
296
341
  }
297
342
 
298
- // 5. MUTATE. Decrement BEFORE the call so a throwing mutation still consumes budget —
299
- // otherwise a failing destructive tool could be retried without limit.
300
- remaining -= 1;
301
- if (remaining <= 0) {
302
- for (const h of handles) h.disable();
303
- server.sendToolListChanged();
343
+ // 6. MUTATE.
344
+ let result: McpListResult;
345
+ try {
346
+ result = await handler(args);
347
+ } catch (e) {
348
+ // A tool that threw may still have changed something first. If recording the failure fails
349
+ // too, the tool's own error still leads what the call reports: an audit error alone reads
350
+ // as a write that never started, and so as one that is safe to try again.
351
+ try {
352
+ await record(name, "failed", { target, preState, error: messageOf(e) });
353
+ } catch (error_) {
354
+ throw new Error(
355
+ `${name} failed: ${messageOf(e)}. Recording that failure also failed: ${messageOf(error_)}`,
356
+ { cause: e },
357
+ );
358
+ }
359
+ throw e;
304
360
  }
361
+ // Outside the `try`: the mutation has happened, so a failure to RECORD it must not be
362
+ // recorded as the mutation failing, which it was when both shared one `catch`. The call still
363
+ // rejects, saying the write ran, so it is not taken for one that is safe to try again.
305
364
  try {
306
- const result = await handler(args);
307
365
  await record(name, "executed", { target, preState });
308
- return result;
309
366
  } catch (e) {
310
- await record(name, "failed", {
311
- target,
312
- preState,
313
- error: e instanceof Error ? e.message : String(e),
367
+ throw new Error(`${name} ran, but recording that it ran failed: ${messageOf(e)}`, {
368
+ cause: e,
314
369
  });
315
- throw e;
316
370
  }
371
+ return result;
317
372
  };
318
373
 
319
374
  pending.push(() => {
@@ -1,7 +1,70 @@
1
- import { spawn } from "node:child_process";
1
+ /**
2
+ * nimbus-spawn — the one place this package starts a process.
3
+ *
4
+ * Every connector that reaches its service through a CLI spawns it through {@link nimbusSpawn},
5
+ * directly or by way of `run-cli-json.ts` and `cli-json-kit.ts`, and `scripts/spawn-chokepoint.test.ts`
6
+ * fails any other shipped file that imports a module able to start a process, in any import form,
7
+ * or refers to the `Bun` global at all. That is what makes a rule enforced here hold for every
8
+ * connector: today, `windows-batch-args.ts`'s refusal of an argument cmd.exe would act on, when the
9
+ * program may start a Windows batch file. Both implementations build the child's environment once
10
+ * and hand the SAME object to that check and to the spawn, so the environment that was checked is
11
+ * the one the child gets.
12
+ */
13
+
14
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
15
+ import { batchArgumentRefusal } from "./windows-batch-args.ts";
2
16
 
3
17
  export type SpawnResult = { code: number; stdout: string; stderr: string };
4
18
 
19
+ /**
20
+ * The child's environment for `env`, and why `command` must not be spawned with it, if it must
21
+ * not. Nothing is spawned for a refused command: it resolves as a failure whose stderr says so.
22
+ */
23
+ function prepareSpawn(
24
+ command: readonly string[],
25
+ env: Record<string, string | undefined>,
26
+ ): { childEnv: Record<string, string | undefined>; refusal: SpawnResult | undefined } {
27
+ const childEnv = { ...process.env, ...env };
28
+ const refusal = batchArgumentRefusal(command, childEnv);
29
+ return {
30
+ childEnv,
31
+ refusal: refusal === undefined ? undefined : { code: 1, stdout: "", stderr: refusal },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Collect a child's output and resolve with it once the child closes or fails to start.
37
+ *
38
+ * Output is accumulated as raw `Buffer`s and decoded ONCE at the end. Decoding each chunk with
39
+ * `chunk.toString("utf8")` corrupts any multi-byte character that straddles a chunk boundary, and
40
+ * chunk boundaries are a function of pipe timing — so it fails intermittently, on non-ASCII data,
41
+ * in production.
42
+ */
43
+ function collectOutput(
44
+ child: ChildProcessWithoutNullStreams,
45
+ resolveP: (result: SpawnResult) => void,
46
+ ): void {
47
+ const outChunks: Buffer[] = [];
48
+ const errChunks: Buffer[] = [];
49
+ child.stdout.on("data", (c: Buffer) => {
50
+ outChunks.push(c);
51
+ });
52
+ child.stderr.on("data", (c: Buffer) => {
53
+ errChunks.push(c);
54
+ });
55
+ const decode = (): { stdout: string; stderr: string } => ({
56
+ stdout: Buffer.concat(outChunks).toString("utf8"),
57
+ stderr: Buffer.concat(errChunks).toString("utf8"),
58
+ });
59
+ child.on("error", (e: Error) => {
60
+ const d = decode();
61
+ resolveP({ code: 1, stdout: d.stdout, stderr: `${d.stderr}${e.message}` });
62
+ });
63
+ child.on("close", (code: number | null) => {
64
+ resolveP({ code: code ?? 1, ...decode() });
65
+ });
66
+ }
67
+
5
68
  /**
6
69
  * Node implementation. Exported so it can be tested directly: the suite runs under Bun, so
7
70
  * `nimbusSpawn` would otherwise never exercise this branch and it would ship unverified.
@@ -15,11 +78,6 @@ export type SpawnResult = { code: number; stdout: string; stderr: string };
15
78
  * `Bun.spawn` path reads stdout uncapped, and `aws logs` / `gcloud logging` JSON routinely
16
79
  * exceeds 1 MB, so `execFile` would be a silent-truncation regression dressed up as a
17
80
  * portability fix.
18
- *
19
- * Output is accumulated as raw `Buffer`s and decoded ONCE at the end. Decoding each chunk with
20
- * `chunk.toString("utf8")` corrupts any multi-byte character that straddles a chunk boundary, and
21
- * chunk boundaries are a function of pipe timing — so it fails intermittently, on non-ASCII data,
22
- * in production.
23
81
  */
24
82
  export function spawnViaNode(
25
83
  command: readonly string[],
@@ -29,27 +87,20 @@ export function spawnViaNode(
29
87
  if (bin === undefined) {
30
88
  return Promise.resolve({ code: 1, stdout: "", stderr: "empty command" });
31
89
  }
90
+ const { childEnv, refusal } = prepareSpawn(command, env);
91
+ if (refusal !== undefined) {
92
+ return Promise.resolve(refusal);
93
+ }
32
94
  return new Promise((resolveP) => {
33
- const child = spawn(bin, args, { env: { ...process.env, ...env } });
34
- const outChunks: Buffer[] = [];
35
- const errChunks: Buffer[] = [];
36
- child.stdout.on("data", (c: Buffer) => {
37
- outChunks.push(c);
38
- });
39
- child.stderr.on("data", (c: Buffer) => {
40
- errChunks.push(c);
41
- });
42
- const decode = (): { stdout: string; stderr: string } => ({
43
- stdout: Buffer.concat(outChunks).toString("utf8"),
44
- stderr: Buffer.concat(errChunks).toString("utf8"),
45
- });
46
- child.on("error", (e: Error) => {
47
- const d = decode();
48
- resolveP({ code: 1, stdout: d.stdout, stderr: `${d.stderr}${e.message}` });
49
- });
50
- child.on("close", (code: number | null) => {
51
- resolveP({ code: code ?? 1, ...decode() });
52
- });
95
+ // `spawn` THROWS, rather than emitting "error", for a command it cannot start at all: an
96
+ // argument holding a NUL byte, or — on Node, though not in Bun's `node:child_process` — a
97
+ // `.cmd` or `.bat` without a shell, which Node refuses with EINVAL. Uncaught, the throw would
98
+ // REJECT this promise, against the contract both implementations keep.
99
+ try {
100
+ collectOutput(spawn(bin, args, { env: childEnv }), resolveP);
101
+ } catch (e) {
102
+ resolveP({ code: 1, stdout: "", stderr: e instanceof Error ? e.message : String(e) });
103
+ }
53
104
  });
54
105
  }
55
106
 
@@ -61,12 +112,16 @@ export async function spawnViaBun(
61
112
  if (command.length === 0) {
62
113
  return { code: 1, stdout: "", stderr: "empty command" };
63
114
  }
115
+ const { childEnv, refusal } = prepareSpawn(command, env);
116
+ if (refusal !== undefined) {
117
+ return refusal;
118
+ }
64
119
  // `Bun.spawn` THROWS synchronously when the binary does not exist, where `child_process` emits
65
120
  // an "error" event instead. Both branches must honour the same contract — resolve with a
66
121
  // non-zero code, never reject — or a caller's behaviour would depend on the runtime.
67
122
  try {
68
123
  const proc = Bun.spawn([...command], {
69
- env: { ...process.env, ...env },
124
+ env: childEnv,
70
125
  stdout: "pipe",
71
126
  stderr: "pipe",
72
127
  });
@@ -119,6 +174,8 @@ export function selectSpawnImpl(g: unknown = globalThis): SpawnImpl {
119
174
  * routing unconditionally through Node made it spawn a real `aws` and hang the suite.
120
175
  *
121
176
  * Never rejects: a spawn failure resolves with a non-zero code, matching the previous behaviour.
177
+ * So does a REFUSED spawn — on Windows, an argument cmd.exe would act on, for a program that may
178
+ * start a batch file — whose stderr says why and that nothing was run.
122
179
  */
123
180
  export function nimbusSpawn(
124
181
  command: readonly string[],
@@ -1,40 +1,70 @@
1
1
  /**
2
2
  * Guard a tool-supplied value before it is passed as an argument to a spawned CLI
3
- * (`gcloud` / `aws` / `bq` / …).
3
+ * (`gcloud` / `aws` / `az` / `kubectl` / …).
4
4
  *
5
5
  * Spawns use an argv array (no shell), so there is no classic shell injection — but
6
6
  * a value that begins with `-` would be parsed by the CLI as a FLAG rather than a
7
7
  * positional / flag-value. This "argv flag smuggling" lets tool input inject
8
- * arbitrary CLI flags (e.g. a `sinkName` of `--project=attacker` or `-h`). Reject
8
+ * arbitrary CLI flags (e.g. a `sinkName` of `--project=attacker`, or a pod name of
9
+ * `--kubeconfig=<path>`, whose exec credential plugin runs a command). Reject
9
10
  * any value that is empty, over-long, starts with `-`, or contains control
10
11
  * characters.
11
12
  *
12
13
  * Resource-id charsets vary per service (sink ids are `[A-Za-z0-9_.-]`, CloudWatch
13
14
  * log-group names contain `/`, BigQuery refs contain `.`), so this is the UNIVERSAL
14
15
  * guard against flag smuggling; callers may layer a stricter per-resource regex on
15
- * top. Returns the value unchanged so it can be used inline:
16
+ * top. Two CLIs need one: `aws` and `az` read some argument values from a file
17
+ * instead of taking them as written — see {@link awsCliArgProblem} and
18
+ * {@link azCliArgProblem}. A third rule is not per value at all: an argument to a CLI
19
+ * that Windows runs as a batch file is checked at the spawn itself, in
20
+ * `windows-batch-args.ts`.
21
+ *
22
+ * Returns the value unchanged so it can be used inline:
16
23
  * `cli(["describe", assertSafeCliArg(p.id, "id")])`.
17
24
  *
18
25
  * @throws {Error} when the value is unsafe to pass as a CLI argument.
19
26
  */
20
27
  export function assertSafeCliArg(value: string, label = "argument"): string {
28
+ const problem = cliArgProblem(value);
29
+ if (problem !== undefined) {
30
+ throw new Error(`Invalid ${label}: ${problem}`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ /** The longest value accepted as one CLI argument. */
36
+ const MAX_CLI_ARG_LENGTH = 1024;
37
+
38
+ /**
39
+ * Why `value` must not be passed to a spawned CLI as one argument, or `undefined` when it may.
40
+ *
41
+ * The reason is the whole of the message a refusal carries, so it says what to change. It is the
42
+ * rule {@link assertSafeCliArg} throws on and {@link isSafeCliArg} tests, written once so that the
43
+ * two can never disagree.
44
+ */
45
+ export function cliArgProblem(value: unknown): string | undefined {
21
46
  if (typeof value !== "string" || value.length === 0) {
22
- throw new Error(`Invalid ${label}: must be a non-empty string`);
47
+ return "must be a non-empty string";
23
48
  }
24
- if (value.length > 1024) {
25
- throw new Error(`Invalid ${label}: exceeds 1024 characters`);
49
+ if (value.length > MAX_CLI_ARG_LENGTH) {
50
+ return `exceeds ${String(MAX_CLI_ARG_LENGTH)} characters`;
26
51
  }
27
52
  if (value.startsWith("-")) {
28
- throw new Error(
29
- `Invalid ${label}: must not start with "-" (argv flag smuggling is not allowed)`,
30
- );
53
+ return 'must not start with "-" (argv flag smuggling is not allowed)';
31
54
  }
55
+ if (hasControlCharacter(value)) {
56
+ return "must not contain control characters";
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function hasControlCharacter(value: string): boolean {
32
62
  for (let i = 0; i < value.length; i += 1) {
33
63
  if ((value.codePointAt(i) ?? 0) < 0x20) {
34
- throw new Error(`Invalid ${label}: must not contain control characters`);
64
+ return true;
35
65
  }
36
66
  }
37
- return value;
67
+ return false;
38
68
  }
39
69
 
40
70
  /**
@@ -43,13 +73,105 @@ export function assertSafeCliArg(value: string, label = "argument"): string {
43
73
  * schema boundary before the handler ever shells out.
44
74
  */
45
75
  export function isSafeCliArg(value: unknown): value is string {
76
+ return cliArgProblem(value) === undefined;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // aws
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * The prefixes with which the AWS CLI REPLACES a parameter's value by what it reads from
85
+ * somewhere else. Matched here case-insensitively and after leading whitespace, though the CLI
86
+ * matches them exactly: refusing a value the CLI would have taken as written costs nothing.
87
+ *
88
+ * `file://` and `fileb://` read a local file — for nearly every parameter, not only the ones
89
+ * documented as taking a document; the CLI exempts a short list whose value is itself a URL — and
90
+ * v1 of the CLI fetches an `http://` or `https://` value too, unless `cli_follow_urlparam` is
91
+ * turned off. The CLI then sends what it read to AWS as the value, and an
92
+ * error can quote it back — AWS's own validation errors name the value they reject — so a tool
93
+ * argument of `file://<path>` could hand the model the contents of a file on the user's machine.
94
+ */
95
+ export const AWS_CLI_LOADING_PREFIXES = ["file://", "fileb://", "http://", "https://"] as const;
96
+
97
+ function awsPrefixProblem(value: string): string | undefined {
98
+ const head = value.trimStart().toLowerCase();
99
+ const prefix = AWS_CLI_LOADING_PREFIXES.find((p) => head.startsWith(p));
100
+ return prefix === undefined
101
+ ? undefined
102
+ : `must not start with "${prefix}" (the aws CLI would read the value from that location)`;
103
+ }
104
+
105
+ /**
106
+ * `@=` is the AWS CLI v2 shorthand-syntax operator that loads a file into one key of a structure
107
+ * parameter, as in `TemplateBody@=file://<path>`. No tool passes a structure parameter today, so
108
+ * this is refused for the next tool that does rather than for any value that reaches the CLI now.
109
+ */
110
+ function awsShorthandProblem(value: string): string | undefined {
111
+ return value.includes("@=")
112
+ ? 'must not contain "@=" (aws shorthand syntax would read a file into the value)'
113
+ : undefined;
114
+ }
115
+
116
+ /**
117
+ * Why `value` must not be passed to the AWS CLI as one argument, or `undefined` when it may: the
118
+ * universal rule, then a loading prefix ({@link AWS_CLI_LOADING_PREFIXES}), then `@=`.
119
+ */
120
+ export function awsCliArgProblem(value: unknown): string | undefined {
46
121
  if (typeof value !== "string") {
47
- return false;
122
+ return cliArgProblem(value);
123
+ }
124
+ return cliArgProblem(value) ?? awsPrefixProblem(value) ?? awsShorthandProblem(value);
125
+ }
126
+
127
+ /**
128
+ * Why `value` must not be passed to the AWS CLI as a DOCUMENT — a CloudFormation template body —
129
+ * or `undefined` when it may.
130
+ *
131
+ * A document legitimately spans lines and runs past the 1024 characters an argument is held to,
132
+ * so of the argument rules only the two about where the CLI looks remain: a leading `-`, which the
133
+ * CLI would parse as an option, and a loading prefix, which would make it read the "document" from
134
+ * a file. `@=` is not among them: shorthand syntax applies to structure parameters, and a template
135
+ * body is a string.
136
+ */
137
+ export function awsCliDocumentProblem(value: unknown): string | undefined {
138
+ if (typeof value !== "string" || value.length === 0) {
139
+ return "must be a non-empty string";
140
+ }
141
+ if (value.startsWith("-")) {
142
+ return 'must not start with "-" (argv flag smuggling is not allowed)';
143
+ }
144
+ return awsPrefixProblem(value);
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // az
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function azFileProblem(value: string): string | undefined {
152
+ if (value.startsWith("@")) {
153
+ return 'must not start with "@" (az would read the value from a file)';
154
+ }
155
+ if (value.includes("=@")) {
156
+ return 'must not contain "=@" (az would read what follows from a file)';
48
157
  }
49
- try {
50
- assertSafeCliArg(value);
51
- return true;
52
- } catch {
53
- return false;
158
+ return undefined;
159
+ }
160
+
161
+ /**
162
+ * Why `value` must not be passed to the Azure CLI as one argument, or `undefined` when it may: the
163
+ * universal rule, then a leading `@`, then `=@`.
164
+ *
165
+ * Before it parses anything, `az` replaces every argument that starts with `@` by the contents of
166
+ * the file named after it — with `~` expanded, and `@-` read from stdin — and does the same to
167
+ * whatever follows the first `=` in an argument. A resource group of
168
+ * `@~/.azure/msal_token_cache.json` would be sent to Azure as that cache, which a "could not be
169
+ * found" error can quote back. An `@` anywhere else, as in an email address, is left alone by `az`
170
+ * and is accepted here.
171
+ */
172
+ export function azCliArgProblem(value: unknown): string | undefined {
173
+ if (typeof value !== "string") {
174
+ return cliArgProblem(value);
54
175
  }
176
+ return cliArgProblem(value) ?? azFileProblem(value);
55
177
  }