@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
@@ -0,0 +1,274 @@
1
+ /**
2
+ * windows-batch-args — refuse an argument cmd.exe would act on, when the CLI being spawned may
3
+ * start a Windows batch file.
4
+ *
5
+ * Every spawn in this package passes an argv array with no shell, so on Linux and macOS each
6
+ * argument reaches the CLI byte for byte. Windows breaks that for one kind of executable. A batch
7
+ * file — a `.cmd` or `.bat` — cannot be started on its own, so CreateProcess runs it as
8
+ * `cmd.exe /c <command line>`, and cmd.exe parses the whole command line again before the batch
9
+ * file sees any of it. `az` and `gcloud` are batch files on Windows. Measured with Bun 1.3.14, which
10
+ * resolves a bare `az` to `az.cmd` whenever the spawn environment spells the variable `PATH`, and
11
+ * whose `node:child_process` launches one the same way:
12
+ *
13
+ * - `&`, `|`, `<` and `>` in an argument the runtime did not quote — any argument without a space
14
+ * — start a second command, a pipe or a redirection: `x&echo,INJECTED` printed `INJECTED`.
15
+ * - `"` ends the quoting the runtime added, so an argument with a space is no defence:
16
+ * `q"&echo INJECTED&"` ran the `echo` too.
17
+ * - `%NAME%` is replaced by the value of an environment variable even inside quotes, and `!NAME!`
18
+ * is too in a batch file that turns on delayed expansion — so a credential in the connector's
19
+ * environment would be sent to the cloud API as the argument, where an error could quote it.
20
+ * - `(` and `)` close a parenthesised block early. `az.cmd` runs the CLI inside one, and in a
21
+ * batch file of that shape an unquoted argument holding `)` is a syntax error — "... was
22
+ * unexpected at this time" — before the CLI starts.
23
+ * - `^` is consumed as an escape, and a line break ends the command.
24
+ *
25
+ * Quoting for cmd.exe is possible, but it is a second grammar layered on the first, with `%` and
26
+ * `!` that no quote stops; refusing is simpler and can be verified. What the refusal costs was
27
+ * checked against every value the connectors pass: none of these characters is valid in a GCP or
28
+ * AWS resource name, an Azure app, cluster or node pool name, or a subscription id. An Azure
29
+ * resource group may hold `(` and `)`, but never a space, so the runtime never quotes it and
30
+ * `az.cmd` already failed on it as above. What did work and is now refused: an Azure subscription
31
+ * passed by a display name holding one of these characters and a space — its id still works. A
32
+ * CloudFormation template, full of quotes, was refused too whenever the `aws` that runs is a batch
33
+ * file, until it began to reach `aws` as a file instead of an argument.
34
+ *
35
+ * Which file runs is decided the way the runtime decides it — see {@link mayRunAsBatchFile} — so
36
+ * an `aws.exe` found first is never refused, whatever batch file of that name comes later.
37
+ *
38
+ * Node itself never gets this far: it resolves a bare name only to `.com` or `.exe`, and it has
39
+ * refused to spawn a `.cmd` or `.bat` without a shell since 18.20.2 / 20.12.2 (CVE-2024-27980).
40
+ * The rule applies to both runtimes all the same, since the check costs nothing where Node would
41
+ * have refused anyway.
42
+ */
43
+
44
+ import { statSync } from "node:fs";
45
+ import { win32 } from "node:path";
46
+
47
+ /** The characters cmd.exe acts on when it parses a command line again, line breaks aside. */
48
+ const CMD_METACHARACTERS: ReadonlySet<string> = new Set([
49
+ "%",
50
+ "!",
51
+ '"',
52
+ "&",
53
+ "|",
54
+ "<",
55
+ ">",
56
+ "^",
57
+ "(",
58
+ ")",
59
+ ]);
60
+
61
+ /** The extensions CreateProcess hands to cmd.exe. */
62
+ const BATCH_EXTENSIONS = [".cmd", ".bat"] as const;
63
+
64
+ /** The extensions Bun tries, in this order, on a name it looks up on Windows. */
65
+ const LOOKUP_EXTENSIONS = [".exe", ".cmd", ".bat"] as const;
66
+
67
+ /**
68
+ * The first character of `value` that cmd.exe would act on — one of `% ! " & | < > ^ ( )`, or a
69
+ * control character such as a line break — or `undefined` when there is none.
70
+ */
71
+ export function cmdMetacharacterIn(value: string): string | undefined {
72
+ for (const ch of value) {
73
+ if (CMD_METACHARACTERS.has(ch) || (ch.codePointAt(0) ?? 0) < 0x20) {
74
+ return ch;
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Whether Windows runs a file of this name through cmd.exe: a `.cmd` or a `.bat`, in any case,
82
+ * after the trailing dots and spaces Windows drops from a file name — `az.cmd.` opens `az.cmd`.
83
+ */
84
+ export function isBatchFileName(name: string): boolean {
85
+ const trimmed = name.replace(/[. ]+$/, "").toLowerCase();
86
+ return BATCH_EXTENSIONS.some((ext) => trimmed.endsWith(ext));
87
+ }
88
+
89
+ /**
90
+ * Whether Bun looks `name` up exactly as written instead of trying extensions on it: when it ends
91
+ * in `.exe`, `.cmd` or `.bat`, in any case. `.com` is not among them, so `x.com` is looked up as
92
+ * `x.com.exe`, `x.com.cmd` and `x.com.bat`.
93
+ */
94
+ function endsWithLookupExtension(name: string): boolean {
95
+ return /\.(?:exe|cmd|bat)$/i.test(name);
96
+ }
97
+
98
+ /** The filesystem a batch-file check looks at. Production leaves both to their defaults. */
99
+ export interface BatchFileProbe {
100
+ /**
101
+ * Whether a FILE exists at a path. A directory does not count, as it does not for Bun's lookup.
102
+ * Defaults to a `statSync` check, which follows a link to what it points at.
103
+ */
104
+ readonly exists?: (path: string) => boolean;
105
+ /**
106
+ * The directory a relative name or `PATH` entry is resolved against. Defaults to
107
+ * `process.cwd()`: nothing here spawns with a `cwd` of its own.
108
+ */
109
+ readonly cwd?: string;
110
+ }
111
+
112
+ /** Whether a file — not a directory — exists at `path`. */
113
+ function isFile(path: string): boolean {
114
+ try {
115
+ return statSync(path).isFile();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The value of each PATH variable of `env` that is set and not empty, in every spelling of its
123
+ * name: `PATH`, the one Bun reads, and `Path`, which is how Windows spells it and which a copy of
124
+ * a Windows-launched process's environment keeps.
125
+ */
126
+ function pathValues(env: Record<string, string | undefined>): string[] {
127
+ const values: string[] = [];
128
+ for (const [key, value] of Object.entries(env)) {
129
+ if (key.toLowerCase() === "path" && value !== undefined && value !== "") {
130
+ values.push(value);
131
+ }
132
+ }
133
+ return values;
134
+ }
135
+
136
+ /**
137
+ * The orders in which a PATH value's directories are searched: its entries as written, which is
138
+ * how Bun reads them, and — when that differs — with whitespace and surrounding quotes removed
139
+ * from each, as cmd.exe reads them. An empty entry is skipped, as Bun skips it.
140
+ */
141
+ function searchOrders(value: string): string[][] {
142
+ const written = value.split(win32.delimiter).filter((entry) => entry !== "");
143
+ const cleaned = written
144
+ .map((entry) => entry.trim().replace(/^"(.*)"$/, "$1"))
145
+ .filter((entry) => entry !== "");
146
+ const same =
147
+ cleaned.length === written.length && cleaned.every((entry, i) => entry === written[i]);
148
+ return same ? [written] : [written, cleaned];
149
+ }
150
+
151
+ /**
152
+ * Whether the first of `bases` that a file `<base>.exe`, `<base>.cmd` or `<base>.bat` exists for
153
+ * has a `.cmd` or `.bat` one: the lookup stops at the first such place, and that place decides.
154
+ */
155
+ function firstHolderIsBatch(bases: readonly string[], exists: (path: string) => boolean): boolean {
156
+ for (const base of bases) {
157
+ const found = LOOKUP_EXTENSIONS.filter((ext) => exists(base + ext));
158
+ if (found.length > 0) {
159
+ return found.some((ext) => ext !== ".exe");
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Whether spawning `bin` with `env` may start a batch file — whether the file that runs would be a
167
+ * `.cmd` or a `.bat`.
168
+ *
169
+ * That depends on how the runtime turns a name into a file, and this answers it the way Bun does
170
+ * on Windows: read from its source, unchanged from 1.2.0 to 1.3.14, and held to what the real
171
+ * lookup does by `windows-batch-args.test.ts` on Windows.
172
+ *
173
+ * - A name ending in `.cmd` or `.bat`, after the trailing dots and spaces Windows drops from a
174
+ * file name, is one: every runtime that starts it hands it to cmd.exe.
175
+ * - A name ending in `.exe` is looked up as written, and an `.exe` never is one.
176
+ * - Bun looks any other name up only when the spawn environment has a non-empty variable spelled
177
+ * exactly `PATH`. A bare name is looked for in each of that variable's entries in turn, and a
178
+ * name written with a `\` in its own directory alone, each time as `<name>.exe`, `<name>.cmd`
179
+ * and `<name>.bat`, and the first file found is what runs. So an `aws.exe` found before any
180
+ * `aws.cmd` is not a batch file, whatever comes after it. The current directory is not searched
181
+ * for a bare name.
182
+ * - Otherwise — and for any name written with a `/` — Bun hands the name to libuv, as Node does,
183
+ * and libuv starts only a `.com` or an `.exe`: never a batch file.
184
+ *
185
+ * It says yes where Bun could change without notice, and nowhere else: a PATH variable counts in
186
+ * any spelling, not only `PATH` — Bun reads no other, though `nimbus-spawn.ts` respells the
187
+ * variable `PATH` before every spawn, so a bare `az` under `Path` starts too — and each is searched
188
+ * both as written and with whitespace and quotes stripped from its entries; a name written with a
189
+ * `/` is looked up in its directory as one with a `\` is; and the first place holding the name
190
+ * answers yes if it holds a `.cmd` or `.bat` by that name, even beside an `.exe` Bun would try
191
+ * first.
192
+ */
193
+ export function mayRunAsBatchFile(
194
+ bin: string,
195
+ env: Record<string, string | undefined>,
196
+ probe: BatchFileProbe = {},
197
+ ): boolean {
198
+ if (isBatchFileName(bin)) {
199
+ return true;
200
+ }
201
+ if (bin === "" || endsWithLookupExtension(bin)) {
202
+ return false;
203
+ }
204
+ const paths = pathValues(env);
205
+ if (paths.length === 0) {
206
+ return false;
207
+ }
208
+ const exists = probe.exists ?? isFile;
209
+ const cwd = probe.cwd ?? process.cwd();
210
+ if (bin.includes("/") || bin.includes("\\")) {
211
+ return firstHolderIsBatch([win32.resolve(cwd, bin)], exists);
212
+ }
213
+ return paths.some((value) =>
214
+ searchOrders(value).some((dirs) =>
215
+ firstHolderIsBatch(
216
+ dirs.map((dir) => win32.resolve(cwd, dir, bin)),
217
+ exists,
218
+ ),
219
+ ),
220
+ );
221
+ }
222
+
223
+ /** A character named the way a refusal message can show it. */
224
+ function describeCharacter(ch: string): string {
225
+ const code = ch.codePointAt(0) ?? 0;
226
+ return code < 0x20
227
+ ? `the control character U+${code.toString(16).toUpperCase().padStart(4, "0")}`
228
+ : JSON.stringify(ch);
229
+ }
230
+
231
+ /** Options for {@link batchArgumentRefusal}: the filesystem probe, and the platform it applies on. */
232
+ export interface BatchArgumentOptions extends BatchFileProbe {
233
+ /** Defaults to `process.platform`. The rule applies only on `"win32"`. */
234
+ readonly platform?: NodeJS.Platform;
235
+ }
236
+
237
+ /**
238
+ * Why `command` must not be spawned with `env`, or `undefined` when it may: on Windows, when the
239
+ * program may start a batch file ({@link mayRunAsBatchFile}) and an argument holds a character
240
+ * cmd.exe would act on ({@link cmdMetacharacterIn}).
241
+ *
242
+ * The arguments are scanned first, so the filesystem is consulted only for a command that would
243
+ * be refused if its program were a batch file — an ordinary spawn costs no lookups at all. The
244
+ * message names the argument by position and the character, never the value, which may be long
245
+ * or private.
246
+ */
247
+ export function batchArgumentRefusal(
248
+ command: readonly string[],
249
+ env: Record<string, string | undefined>,
250
+ options: BatchArgumentOptions = {},
251
+ ): string | undefined {
252
+ if ((options.platform ?? process.platform) !== "win32") {
253
+ return undefined;
254
+ }
255
+ const [bin, ...args] = command;
256
+ if (bin === undefined) {
257
+ return undefined;
258
+ }
259
+ for (const [index, arg] of args.entries()) {
260
+ const ch = cmdMetacharacterIn(arg);
261
+ if (ch === undefined) {
262
+ continue;
263
+ }
264
+ if (!mayRunAsBatchFile(bin, env, options)) {
265
+ return undefined;
266
+ }
267
+ return (
268
+ `refused to run ${JSON.stringify(bin)}: it may start a Windows batch file, which cmd.exe ` +
269
+ `parses again before the CLI sees its arguments, and argument ${String(index + 1)} holds ` +
270
+ `${describeCharacter(ch)}, which cmd.exe would act on. Nothing was run.`
271
+ );
272
+ }
273
+ return undefined;
274
+ }
@@ -0,0 +1,100 @@
1
+ /** The variable that sets how many mutations a standalone connector may perform in one session. */
2
+ export const WRITE_BUDGET_ENV = "NIMBUS_MCP_WRITE_BUDGET";
3
+
4
+ /** Mutations allowed per process lifetime when `NIMBUS_MCP_WRITE_BUDGET` is unset. */
5
+ export const DEFAULT_WRITE_BUDGET = 10;
6
+
7
+ /**
8
+ * The largest budget accepted, `2 ** 53 - 1`, which is `Number.MAX_SAFE_INTEGER`.
9
+ *
10
+ * Above it a budget stops counting down one mutation at a time: `2 ** 54 - 1` rounds back to
11
+ * `2 ** 54`, so a budget that large would never run out. That is an unlimited cap spelled as a
12
+ * number, and it is refused like any other value that cannot be enforced as written.
13
+ */
14
+ export const MAX_WRITE_BUDGET = Number.MAX_SAFE_INTEGER;
15
+
16
+ /**
17
+ * Plain decimal digits: no sign, fraction, exponent, `0x` prefix, separator or `Infinity`. In a
18
+ * JavaScript regex `\d` is ASCII `0`-`9` only, with or without the `u` flag, so a digit from
19
+ * another script does not match.
20
+ */
21
+ const DIGITS = /^\d+$/;
22
+
23
+ function invalidBudget(raw: string): Error {
24
+ return new Error(
25
+ `${WRITE_BUDGET_ENV}=${JSON.stringify(raw)}: expected a whole number of mutations from 0 to ` +
26
+ `${String(MAX_WRITE_BUDGET)}, where 0 refuses every write; leave it unset for the default, ` +
27
+ `${String(DEFAULT_WRITE_BUDGET)}`,
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Parse `NIMBUS_MCP_WRITE_BUDGET` into the number of mutations a session may perform.
33
+ *
34
+ * STRICT, and an invalid value THROWS at startup, as `parseWriteScope` does on a malformed scope
35
+ * term. The budget used to be read with `Number()`, and that failed open: `Number("abc")` is
36
+ * `NaN`, `NaN <= 0` is false, so the budget never ran out. Measured on argocd standalone with 25
37
+ * approved syncs, unset allowed 10 while `abc`, `ten` and `Infinity` each allowed all 25.
38
+ *
39
+ * Throwing rather than falling back to the default is deliberate. A fallback still runs under a
40
+ * cap the operator did not write, and it can be a LARGER one: an operator who wrote `none` or
41
+ * `zero` meant no writes at all and would get ten. A typo in a security setting should stop the
42
+ * connector, which the client reports as a failed server, not be repaired behind a stderr warning
43
+ * the operator may never see.
44
+ *
45
+ * Accepted: unset, meaning `DEFAULT_WRITE_BUDGET`, or plain decimal digits from `0` (every write
46
+ * refuses) to `MAX_WRITE_BUDGET`, whitespace around them ignored. Everything else throws: a sign,
47
+ * a fraction (even `10.0`), an exponent, a hex or separator spelling, `Infinity`, a number too
48
+ * large to count down exactly, and an EMPTY or whitespace-only value. Every other variable in this
49
+ * package reads empty as unset, but here that reading is not the safe one: `Number("")` is 0, so
50
+ * an empty budget allowed no writes, and reading it as unset would silently raise it to ten. Empty
51
+ * is as likely to mean "none" as "the default", so it is refused rather than guessed at.
52
+ */
53
+ export function parseWriteBudget(raw: string | undefined): number {
54
+ if (raw === undefined) return DEFAULT_WRITE_BUDGET;
55
+ const text = raw.trim();
56
+ // Digits are checked BEFORE `Number()` sees the text: it would read `1e3`, `0x10`, `+5` and
57
+ // `10.0` as numbers the operator never wrote, `Infinity` as no limit at all, and an empty value
58
+ // as 0.
59
+ if (!DIGITS.test(text)) throw invalidBudget(raw);
60
+ // All digits, so never NaN or negative, but a long enough run of them is above the largest safe
61
+ // integer or is `Infinity`. Only a safe integer passes, so what comes out can be counted down
62
+ // exactly, one mutation at a time.
63
+ const n = Number(text);
64
+ if (!Number.isSafeInteger(n)) throw invalidBudget(raw);
65
+ return n;
66
+ }
67
+
68
+ /**
69
+ * A session's write budget: how many more mutations it may perform.
70
+ *
71
+ * The count lives here and nowhere else, so nothing outside can assign it, and its one check,
72
+ * `hasLeft`, passes only a positive safe integer. `parseWriteBudget` yields nothing but a safe
73
+ * whole number, but the check does not rely on that: the count was once tested as
74
+ * `remaining <= 0`, which is false for NaN, so a NaN count never ran out. Written this way, a NaN,
75
+ * an `Infinity` or a fraction allows no writes at all, wherever it came from.
76
+ */
77
+ export type WriteBudget = {
78
+ /** Whether at least one mutation is left. */
79
+ hasLeft(): boolean;
80
+ /**
81
+ * Spend one mutation if one is left, and report whether one was. The check and the decrement
82
+ * are one synchronous call, so calls in flight together cannot all pass the check before any
83
+ * of them spends.
84
+ */
85
+ take(): boolean;
86
+ };
87
+
88
+ /** A budget of `limit` mutations, `limit` being what `parseWriteBudget` returned. */
89
+ export function createWriteBudget(limit: number): WriteBudget {
90
+ let remaining = limit;
91
+ const hasLeft = (): boolean => Number.isSafeInteger(remaining) && remaining > 0;
92
+ return {
93
+ hasLeft,
94
+ take(): boolean {
95
+ if (!hasLeft()) return false;
96
+ remaining -= 1;
97
+ return true;
98
+ },
99
+ };
100
+ }