@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.
- package/README.md +5 -3
- package/connectors/athena/src/tools.ts +12 -10
- package/connectors/aws/src/tools.ts +9 -7
- package/connectors/azure/src/tools.ts +10 -9
- package/connectors/cloud-logging/src/tools.ts +5 -8
- package/connectors/cloudwatch/src/tools.ts +6 -5
- package/connectors/gcp/src/tools.ts +11 -11
- package/connectors/iac/src/tools.ts +8 -7
- package/connectors/kubernetes/src/tools.ts +9 -8
- package/connectors/sagemaker/src/tools.ts +5 -5
- package/package.json +1 -1
- package/shared/audit-chain.ts +588 -28
- package/shared/cli-json-kit.ts +47 -6
- package/shared/consent-kit.ts +81 -26
- package/shared/nimbus-spawn.ts +84 -27
- package/shared/safe-cli-arg.ts +139 -17
- package/shared/windows-batch-args.ts +273 -0
- package/shared/write-budget.ts +100 -0
package/shared/cli-json-kit.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 = (
|
package/shared/consent-kit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
274
|
-
|
|
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.
|
|
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:
|
|
339
|
+
preState = { captureFailed: messageOf(e) };
|
|
295
340
|
}
|
|
296
341
|
}
|
|
297
342
|
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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(() => {
|
package/shared/nimbus-spawn.ts
CHANGED
|
@@ -1,7 +1,70 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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[],
|
package/shared/safe-cli-arg.ts
CHANGED
|
@@ -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` / `
|
|
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
|
|
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.
|
|
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
|
-
|
|
47
|
+
return "must be a non-empty string";
|
|
23
48
|
}
|
|
24
|
-
if (value.length >
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
return true;
|
|
35
65
|
}
|
|
36
66
|
}
|
|
37
|
-
return
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
}
|