@nimbus-dev/connectors 0.2.2 → 0.2.4

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.
Files changed (46) hide show
  1. package/README.md +5 -3
  2. package/connectors/apple/src/server.ts +5 -1
  3. package/connectors/athena/src/tools.ts +12 -10
  4. package/connectors/aws/src/server.ts +5 -1
  5. package/connectors/aws/src/tools.ts +9 -7
  6. package/connectors/azure/src/server.ts +5 -1
  7. package/connectors/azure/src/tools.ts +10 -9
  8. package/connectors/bitbucket/src/server.ts +5 -1
  9. package/connectors/circleci/src/server.ts +5 -1
  10. package/connectors/cloud-logging/src/tools.ts +5 -8
  11. package/connectors/cloudwatch/src/tools.ts +6 -5
  12. package/connectors/confluence/src/server.ts +5 -1
  13. package/connectors/fastmail/src/server.ts +5 -1
  14. package/connectors/gcp/src/server.ts +5 -1
  15. package/connectors/gcp/src/tools.ts +11 -11
  16. package/connectors/github/src/server.ts +2 -0
  17. package/connectors/github-actions/src/server.ts +5 -1
  18. package/connectors/gitlab/src/server.ts +5 -1
  19. package/connectors/gmail/src/server.ts +5 -1
  20. package/connectors/google-drive/src/server.ts +5 -1
  21. package/connectors/iac/src/server.ts +5 -1
  22. package/connectors/iac/src/tools.ts +83 -25
  23. package/connectors/imap/src/server.ts +5 -1
  24. package/connectors/jenkins/src/server.ts +5 -1
  25. package/connectors/jira/src/server.ts +5 -1
  26. package/connectors/kubernetes/src/server.ts +5 -1
  27. package/connectors/kubernetes/src/tools.ts +9 -8
  28. package/connectors/linear/src/server.ts +5 -1
  29. package/connectors/notion/src/server.ts +5 -1
  30. package/connectors/obsidian/src/server.ts +5 -1
  31. package/connectors/onedrive/src/server.ts +5 -1
  32. package/connectors/outlook/src/server.ts +5 -1
  33. package/connectors/pagerduty/src/server.ts +5 -1
  34. package/connectors/protonmail/src/server.ts +5 -1
  35. package/connectors/sagemaker/src/tools.ts +5 -5
  36. package/connectors/slack/src/server.ts +5 -1
  37. package/connectors/teams/src/server.ts +5 -1
  38. package/package.json +1 -1
  39. package/shared/audit-chain.ts +588 -28
  40. package/shared/cli-json-kit.ts +35 -6
  41. package/shared/consent-kit.ts +115 -38
  42. package/shared/nimbus-spawn.ts +114 -27
  43. package/shared/run-read-only-mcp-connector.ts +7 -1
  44. package/shared/safe-cli-arg.ts +119 -17
  45. package/shared/windows-batch-args.ts +274 -0
  46. package/shared/write-budget.ts +100 -0
@@ -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,24 @@
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 { awsCliArgProblem, azCliArgProblem, cliArgProblem } from "./safe-cli-arg.ts";
27
33
 
28
34
  /** Body-snippet length in the thrown error. The value every connector used. */
29
35
  export const DEFAULT_STDERR_SNIPPET = 400;
30
36
 
37
+ /**
38
+ * A Zod string that refuses every value `problem` names a reason against, with that reason as the
39
+ * message — so a refused call says which rule it broke rather than listing all of them.
40
+ */
41
+ function cliArgSchema(problem: (value: unknown) => string | undefined): z.ZodString {
42
+ return z
43
+ .string()
44
+ .min(1)
45
+ .refine((value) => problem(value) === undefined, {
46
+ error: (issue) => problem(issue.input) ?? "is not a safe CLI argument",
47
+ });
48
+ }
49
+
31
50
  /**
32
51
  * A value passed to a CLI as an argument.
33
52
  *
@@ -35,10 +54,20 @@ export const DEFAULT_STDERR_SNIPPET = 400;
35
54
  * `-` would be read by the CLI as a flag rather than as the name it is meant to
36
55
  * be, and control characters have no legitimate place in one.
37
56
  */
38
- export const cliArg = z
39
- .string()
40
- .min(1)
41
- .refine(isSafeCliArg, { message: 'must not start with "-" or contain control characters' });
57
+ export const cliArg = cliArgSchema(cliArgProblem);
58
+
59
+ /**
60
+ * A value passed to the `aws` CLI as an argument: {@link cliArg}, and not a value the CLI would
61
+ * replace by reading a file or fetching a URL (`file://`, `fileb://`, `http://`, `https://`), nor
62
+ * one carrying the shorthand file-load operator `@=`.
63
+ */
64
+ export const awsCliArg = cliArgSchema(awsCliArgProblem);
65
+
66
+ /**
67
+ * A value passed to the `az` CLI as an argument: {@link cliArg}, and not one `az` would replace by
68
+ * a file's contents — a leading `@`, or `=@` anywhere.
69
+ */
70
+ export const azCliArg = cliArgSchema(azCliArgProblem);
42
71
 
43
72
  /** The spawn seam. Production passes `nimbusSpawn`; tests pass a fake. */
44
73
  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
 
@@ -61,6 +59,19 @@ export type ConsentServer = {
61
59
  sendLoggingMessage(params: { level: "info" | "warning"; data: unknown }): Promise<void>;
62
60
  };
63
61
 
62
+ /**
63
+ * What a connector with write tools constructs its `McpServer` with:
64
+ * `new McpServer(info, { capabilities: CONSENT_SERVER_CAPABILITIES })`.
65
+ *
66
+ * The kit tells the client about every gated outcome with `notifications/message`, and the SDK
67
+ * sends that only for a server that declared the MCP `logging` capability. No connector did, so
68
+ * each notification was dropped without a word. It must be declared at construction: that is the
69
+ * only moment the SDK installs its `logging/setLevel` handler, and a server that declared the
70
+ * capability later would refuse a client setting the level. `scripts/connector-boot.test.ts`
71
+ * fails a connector whose manifest declares a write and whose booted server does not declare it.
72
+ */
73
+ export const CONSENT_SERVER_CAPABILITIES = { logging: {} };
74
+
64
75
  export type WriteToolConfig<T> = {
65
76
  /** Action type this tool performs, e.g. "repo.branch.delete". Machine-readable, not prose. */
66
77
  readonly mutates: string;
@@ -84,6 +95,11 @@ function refused(why: string): McpListResult {
84
95
  return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: why }) }] };
85
96
  }
86
97
 
98
+ /** The message of whatever was thrown, which need not be an `Error`. */
99
+ function messageOf(e: unknown): string {
100
+ return e instanceof Error ? e.message : String(e);
101
+ }
102
+
87
103
  /**
88
104
  * Ask the human, through the client, and report whether they said yes.
89
105
  *
@@ -172,30 +188,77 @@ export function createWriteToolRegistrar(
172
188
 
173
189
  const scope = parseWriteScope(process.env[cfg.scopeEnv], cfg.scopeKinds);
174
190
  const auditLog = process.env["NIMBUS_MCP_AUDIT_LOG"];
175
- let remaining = Number(process.env["NIMBUS_MCP_WRITE_BUDGET"] ?? DEFAULT_BUDGET);
191
+ // Throws on a value that is not plain digits, an empty one included, as the scope parse above
192
+ // does on a malformed term: read with `Number()`, `abc` became NaN, which `remaining <= 0` never
193
+ // refused.
194
+ const budget = createWriteBudget(parseWriteBudget(process.env[WRITE_BUDGET_ENV]));
176
195
 
177
196
  async function record(
178
197
  tool: string,
179
198
  outcome: AuditOutcome,
180
199
  detail: Record<string, unknown>,
181
200
  ): Promise<void> {
182
- // Client-visible channel: any MCP client can display or persist this.
183
- await server.sendLoggingMessage({
184
- level: outcome === "executed" ? "info" : "warning",
185
- data: { connector: cfg.connector, tool, outcome },
186
- });
187
- // Durable channel: only when the operator configured a path.
188
- if (auditLog !== undefined && auditLog !== "") {
189
- await appendAuditEntry(auditLog, {
190
- ts: new Date().toISOString(),
191
- connector: cfg.connector,
192
- tool,
193
- outcome,
194
- detail,
195
- });
201
+ try {
202
+ // Durable channel first: only when the operator configured a path. An append that fails
203
+ // rejects, and every record before the mutation is awaited, so a write whose entry did not
204
+ // land never runs.
205
+ if (auditLog !== undefined && auditLog !== "") {
206
+ await appendAuditEntry(auditLog, {
207
+ ts: new Date().toISOString(),
208
+ connector: cfg.connector,
209
+ tool,
210
+ outcome,
211
+ detail,
212
+ });
213
+ }
214
+ } finally {
215
+ // Then the client-visible channel, told of every record attempted, one whose append failed
216
+ // included. It reaches the client because the connector declared the MCP `logging`
217
+ // capability (CONSENT_SERVER_CAPABILITIES), and a client may filter it by level. Best
218
+ // effort, and after the durable log: a client that has gone away mid-write must not keep the
219
+ // `executed` or `failed` record of a write that ran out of the log, and since the capability
220
+ // made this channel live, a dead pipe is where a send now fails.
221
+ await server
222
+ .sendLoggingMessage({
223
+ level: outcome === "executed" ? "info" : "warning",
224
+ data: { connector: cfg.connector, tool, outcome },
225
+ })
226
+ .catch(() => undefined);
196
227
  }
197
228
  }
198
229
 
230
+ /**
231
+ * Take one unit of budget if any is left. Spending the last unit disables every write tool and
232
+ * tells the client.
233
+ *
234
+ * Nothing may be awaited between the check and the decrement, which is why both are the one
235
+ * synchronous call `budget.take()`. Calls run concurrently (the SDK starts each request's
236
+ * handler as it arrives), and with an await between the two every call in flight passed the
237
+ * check before any of them spent: three approved calls all mutated under a budget of one.
238
+ */
239
+ function spendOne(): boolean {
240
+ if (!budget.take()) return false;
241
+ if (!budget.hasLeft()) {
242
+ for (const h of handles) h.disable();
243
+ server.sendToolListChanged();
244
+ }
245
+ return true;
246
+ }
247
+
248
+ /**
249
+ * The refusal for a spent budget, worded the same for the client at both places it is checked.
250
+ * The audit reason tells them apart: `budget exhausted after approval` follows an `accepted`
251
+ * entry, because the budget ran out while that write's prompt was open.
252
+ */
253
+ async function budgetExhausted(
254
+ tool: string,
255
+ target: { kind: string; value: string },
256
+ reason: "budget exhausted" | "budget exhausted after approval",
257
+ ): Promise<McpListResult> {
258
+ await record(tool, "refused", { reason, target });
259
+ return refused("write budget exhausted for this session");
260
+ }
261
+
199
262
  /**
200
263
  * Decide the write surface, once, at the first moment client capabilities are knowable.
201
264
  *
@@ -269,11 +332,9 @@ export function createWriteToolRegistrar(
269
332
  return refused(`out of scope: ${target.kind}:${target.value} is not in ${cfg.scopeEnv}`);
270
333
  }
271
334
 
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
- }
335
+ // 2. BUDGET — same reasoning: a refusal that consent cannot lift comes before consent. Not
336
+ // the last word on the budget, though: see step 4.
337
+ if (!budget.hasLeft()) return budgetExhausted(name, target, "budget exhausted");
277
338
 
278
339
  // 3. CONSENT.
279
340
  await record(name, "requested", { target, params: args });
@@ -283,7 +344,13 @@ export function createWriteToolRegistrar(
283
344
  }
284
345
  await record(name, "accepted", { target });
285
346
 
286
- // 4. PRE-STATE — after approval, before the mutation, so an unrecoverable action leaves a
347
+ // 4. SPEND — the budget checked AGAIN, and decremented in the same synchronous step: every
348
+ // call in flight passed step 2 before any of them got this far, so step 2 alone cannot
349
+ // hold the cap (see `spendOne`). Spent BEFORE the mutation so a throwing one still
350
+ // consumes budget — otherwise a failing destructive tool could be retried without limit.
351
+ if (!spendOne()) return budgetExhausted(name, target, "budget exhausted after approval");
352
+
353
+ // 5. PRE-STATE — after approval, before the mutation, so an unrecoverable action leaves a
287
354
  // record of what it destroyed. Capture failure is NOT fatal: refusing here would turn a
288
355
  // transient read error into a blocked action the owner already approved.
289
356
  let preState: Record<string, unknown> = {};
@@ -291,29 +358,39 @@ export function createWriteToolRegistrar(
291
358
  try {
292
359
  preState = await toolCfg.capturePreState(args);
293
360
  } catch (e) {
294
- preState = { captureFailed: e instanceof Error ? e.message : String(e) };
361
+ preState = { captureFailed: messageOf(e) };
295
362
  }
296
363
  }
297
364
 
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();
365
+ // 6. MUTATE.
366
+ let result: McpListResult;
367
+ try {
368
+ result = await handler(args);
369
+ } catch (e) {
370
+ // A tool that threw may still have changed something first. If recording the failure fails
371
+ // too, the tool's own error still leads what the call reports: an audit error alone reads
372
+ // as a write that never started, and so as one that is safe to try again.
373
+ try {
374
+ await record(name, "failed", { target, preState, error: messageOf(e) });
375
+ } catch (error_) {
376
+ throw new Error(
377
+ `${name} failed: ${messageOf(e)}. Recording that failure also failed: ${messageOf(error_)}`,
378
+ { cause: e },
379
+ );
380
+ }
381
+ throw e;
304
382
  }
383
+ // Outside the `try`: the mutation has happened, so a failure to RECORD it must not be
384
+ // recorded as the mutation failing, which it was when both shared one `catch`. The call still
385
+ // rejects, saying the write ran, so it is not taken for one that is safe to try again.
305
386
  try {
306
- const result = await handler(args);
307
387
  await record(name, "executed", { target, preState });
308
- return result;
309
388
  } catch (e) {
310
- await record(name, "failed", {
311
- target,
312
- preState,
313
- error: e instanceof Error ? e.message : String(e),
389
+ throw new Error(`${name} ran, but recording that it ran failed: ${messageOf(e)}`, {
390
+ cause: e,
314
391
  });
315
- throw e;
316
392
  }
393
+ return result;
317
394
  };
318
395
 
319
396
  pending.push(() => {
@@ -1,7 +1,100 @@
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: `base` with `overrides` laid over it.
21
+ *
22
+ * On Windows a variable's name is case-insensitive, so an override replaces the base's variable
23
+ * whatever its spelling, instead of sitting beside it as a second one that the child may or may
24
+ * not see in its place. And the search path is spelled `PATH`, the only spelling Bun resolves a
25
+ * bare command through. A process started from PowerShell, cmd or Explorer has `Path`, and with it
26
+ * a bare `az` or `gcloud`, which are the batch files `az.cmd` and `gcloud.cmd` there, did not start
27
+ * at all: Bun 1.3.14 answered ENOENT, while an `.exe` such as `aws.exe` still started.
28
+ *
29
+ * Elsewhere names are case-sensitive, and the two are merged as written.
30
+ */
31
+ export function childEnvironment(
32
+ base: Record<string, string | undefined>,
33
+ overrides: Record<string, string | undefined>,
34
+ platform: NodeJS.Platform = process.platform,
35
+ ): Record<string, string | undefined> {
36
+ if (platform !== "win32") {
37
+ return { ...base, ...overrides };
38
+ }
39
+ const byName = new Map<string, readonly [string, string | undefined]>();
40
+ for (const source of [base, overrides]) {
41
+ for (const [key, value] of Object.entries(source)) {
42
+ const name = key.toUpperCase();
43
+ byName.set(name, [name === "PATH" ? "PATH" : key, value]);
44
+ }
45
+ }
46
+ return Object.fromEntries(byName.values());
47
+ }
48
+
49
+ /**
50
+ * The child's environment for `env`, and why `command` must not be spawned with it, if it must
51
+ * not. Nothing is spawned for a refused command: it resolves as a failure whose stderr says so.
52
+ */
53
+ function prepareSpawn(
54
+ command: readonly string[],
55
+ env: Record<string, string | undefined>,
56
+ ): { childEnv: Record<string, string | undefined>; refusal: SpawnResult | undefined } {
57
+ const childEnv = childEnvironment(process.env, env);
58
+ const refusal = batchArgumentRefusal(command, childEnv);
59
+ return {
60
+ childEnv,
61
+ refusal: refusal === undefined ? undefined : { code: 1, stdout: "", stderr: refusal },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Collect a child's output and resolve with it once the child closes or fails to start.
67
+ *
68
+ * Output is accumulated as raw `Buffer`s and decoded ONCE at the end. Decoding each chunk with
69
+ * `chunk.toString("utf8")` corrupts any multi-byte character that straddles a chunk boundary, and
70
+ * chunk boundaries are a function of pipe timing — so it fails intermittently, on non-ASCII data,
71
+ * in production.
72
+ */
73
+ function collectOutput(
74
+ child: ChildProcessWithoutNullStreams,
75
+ resolveP: (result: SpawnResult) => void,
76
+ ): void {
77
+ const outChunks: Buffer[] = [];
78
+ const errChunks: Buffer[] = [];
79
+ child.stdout.on("data", (c: Buffer) => {
80
+ outChunks.push(c);
81
+ });
82
+ child.stderr.on("data", (c: Buffer) => {
83
+ errChunks.push(c);
84
+ });
85
+ const decode = (): { stdout: string; stderr: string } => ({
86
+ stdout: Buffer.concat(outChunks).toString("utf8"),
87
+ stderr: Buffer.concat(errChunks).toString("utf8"),
88
+ });
89
+ child.on("error", (e: Error) => {
90
+ const d = decode();
91
+ resolveP({ code: 1, stdout: d.stdout, stderr: `${d.stderr}${e.message}` });
92
+ });
93
+ child.on("close", (code: number | null) => {
94
+ resolveP({ code: code ?? 1, ...decode() });
95
+ });
96
+ }
97
+
5
98
  /**
6
99
  * Node implementation. Exported so it can be tested directly: the suite runs under Bun, so
7
100
  * `nimbusSpawn` would otherwise never exercise this branch and it would ship unverified.
@@ -15,11 +108,6 @@ export type SpawnResult = { code: number; stdout: string; stderr: string };
15
108
  * `Bun.spawn` path reads stdout uncapped, and `aws logs` / `gcloud logging` JSON routinely
16
109
  * exceeds 1 MB, so `execFile` would be a silent-truncation regression dressed up as a
17
110
  * 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
111
  */
24
112
  export function spawnViaNode(
25
113
  command: readonly string[],
@@ -29,27 +117,20 @@ export function spawnViaNode(
29
117
  if (bin === undefined) {
30
118
  return Promise.resolve({ code: 1, stdout: "", stderr: "empty command" });
31
119
  }
120
+ const { childEnv, refusal } = prepareSpawn(command, env);
121
+ if (refusal !== undefined) {
122
+ return Promise.resolve(refusal);
123
+ }
32
124
  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
- });
125
+ // `spawn` THROWS, rather than emitting "error", for a command it cannot start at all: an
126
+ // argument holding a NUL byte, or — on Node, though not in Bun's `node:child_process` — a
127
+ // `.cmd` or `.bat` without a shell, which Node refuses with EINVAL. Uncaught, the throw would
128
+ // REJECT this promise, against the contract both implementations keep.
129
+ try {
130
+ collectOutput(spawn(bin, args, { env: childEnv }), resolveP);
131
+ } catch (e) {
132
+ resolveP({ code: 1, stdout: "", stderr: e instanceof Error ? e.message : String(e) });
133
+ }
53
134
  });
54
135
  }
55
136
 
@@ -61,12 +142,16 @@ export async function spawnViaBun(
61
142
  if (command.length === 0) {
62
143
  return { code: 1, stdout: "", stderr: "empty command" };
63
144
  }
145
+ const { childEnv, refusal } = prepareSpawn(command, env);
146
+ if (refusal !== undefined) {
147
+ return refusal;
148
+ }
64
149
  // `Bun.spawn` THROWS synchronously when the binary does not exist, where `child_process` emits
65
150
  // an "error" event instead. Both branches must honour the same contract — resolve with a
66
151
  // non-zero code, never reject — or a caller's behaviour would depend on the runtime.
67
152
  try {
68
153
  const proc = Bun.spawn([...command], {
69
- env: { ...process.env, ...env },
154
+ env: childEnv,
70
155
  stdout: "pipe",
71
156
  stderr: "pipe",
72
157
  });
@@ -119,6 +204,8 @@ export function selectSpawnImpl(g: unknown = globalThis): SpawnImpl {
119
204
  * routing unconditionally through Node made it spawn a real `aws` and hang the suite.
120
205
  *
121
206
  * Never rejects: a spawn failure resolves with a non-zero code, matching the previous behaviour.
207
+ * So does a REFUSED spawn — on Windows, an argument cmd.exe would act on, for a program that may
208
+ * start a batch file — whose stderr says why and that nothing was run.
122
209
  */
123
210
  export function nimbusSpawn(
124
211
  command: readonly string[],
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CONSENT_SERVER_CAPABILITIES } from "./consent-kit.ts";
3
4
  import {
4
5
  createRegisterSimpleTool,
5
6
  createZodToolRegistrar,
@@ -41,7 +42,12 @@ export function buildReadOnlyMcpConnector(
41
42
  register: (reg: ZodToolRegistrar, server: unknown) => void,
42
43
  options?: BuildReadOnlyMcpConnectorOptions,
43
44
  ): unknown {
44
- const make = options?.createServer ?? ((info) => new McpServer(info));
45
+ // Every server built here declares the MCP `logging` capability, since the consent kit's
46
+ // notifications reach a client only through it. A truly read-only connector sends no log
47
+ // message, so declaring it costs that connector nothing.
48
+ const make =
49
+ options?.createServer ??
50
+ ((info) => new McpServer(info, { capabilities: CONSENT_SERVER_CAPABILITIES }));
45
51
  const mcp = make({ name: serverName, version: "0.1.0" });
46
52
  const reg = createZodToolRegistrar(createRegisterSimpleTool(mcp));
47
53
  register(reg, mcp);
@@ -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)';
54
+ }
55
+ if (hasControlCharacter(value)) {
56
+ return "must not contain control characters";
31
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,85 @@ 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);
48
123
  }
49
- try {
50
- assertSafeCliArg(value);
51
- return true;
52
- } catch {
53
- return false;
124
+ return cliArgProblem(value) ?? awsPrefixProblem(value) ?? awsShorthandProblem(value);
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // az
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function azFileProblem(value: string): string | undefined {
132
+ if (value.startsWith("@")) {
133
+ return 'must not start with "@" (az would read the value from a file)';
134
+ }
135
+ if (value.includes("=@")) {
136
+ return 'must not contain "=@" (az would read what follows from a file)';
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ /**
142
+ * Why `value` must not be passed to the Azure CLI as one argument, or `undefined` when it may: the
143
+ * universal rule, then a leading `@`, then `=@`.
144
+ *
145
+ * Before it parses anything, `az` replaces every argument that starts with `@` by the contents of
146
+ * the file named after it — with `~` expanded, and `@-` read from stdin — and does the same to
147
+ * whatever follows the first `=` in an argument. A resource group of
148
+ * `@~/.azure/msal_token_cache.json` would be sent to Azure as that cache, which a "could not be
149
+ * found" error can quote back. An `@` anywhere else, as in an email address, is left alone by `az`
150
+ * and is accepted here.
151
+ */
152
+ export function azCliArgProblem(value: unknown): string | undefined {
153
+ if (typeof value !== "string") {
154
+ return cliArgProblem(value);
54
155
  }
156
+ return cliArgProblem(value) ?? azFileProblem(value);
55
157
  }