@nimbus-dev/connectors 0.2.1 → 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.
Files changed (220) hide show
  1. package/README.md +19 -7
  2. package/connectors/airflow/src/search-filter.ts +2 -15
  3. package/connectors/airflow/src/server.ts +2 -87
  4. package/connectors/airflow/src/tools.ts +74 -0
  5. package/connectors/apple/src/adapters.ts +326 -0
  6. package/connectors/apple/src/apple-mail-core.ts +4 -8
  7. package/connectors/apple/src/calendar-tools.ts +28 -8
  8. package/connectors/apple/src/server.ts +23 -512
  9. package/connectors/apple/src/tools.ts +18 -44
  10. package/connectors/argocd/src/server.ts +25 -38
  11. package/connectors/athena/src/tools.ts +26 -43
  12. package/connectors/aws/src/server.ts +2 -183
  13. package/connectors/aws/src/tools.ts +199 -0
  14. package/connectors/azure/src/server.ts +2 -131
  15. package/connectors/azure/src/tools.ts +134 -0
  16. package/connectors/bigeye/src/search-filter.ts +4 -15
  17. package/connectors/bigeye/src/server.ts +38 -38
  18. package/connectors/bigquery/src/tools.ts +1 -26
  19. package/connectors/bitbucket/src/server.ts +2 -244
  20. package/connectors/bitbucket/src/tools.ts +241 -0
  21. package/connectors/bitrise/src/server.ts +2 -79
  22. package/connectors/bitrise/src/tools.ts +76 -0
  23. package/connectors/canva/src/search-filter.ts +2 -11
  24. package/connectors/canva/src/server.ts +2 -73
  25. package/connectors/canva/src/tools.ts +66 -0
  26. package/connectors/circleci/src/server.ts +2 -176
  27. package/connectors/circleci/src/tools.ts +193 -0
  28. package/connectors/cloud-logging/src/tools.ts +19 -35
  29. package/connectors/cloudwatch/src/tools.ts +20 -38
  30. package/connectors/codemagic/src/server.ts +2 -70
  31. package/connectors/codemagic/src/tools.ts +71 -0
  32. package/connectors/confluence/src/server.ts +2 -301
  33. package/connectors/confluence/src/tools.ts +320 -0
  34. package/connectors/dagster/src/server.ts +2 -214
  35. package/connectors/dagster/src/tools.ts +187 -0
  36. package/connectors/databricks/src/search-filter.ts +7 -7
  37. package/connectors/databricks/src/server.ts +2 -77
  38. package/connectors/databricks/src/tools.ts +66 -0
  39. package/connectors/datadog/src/server.ts +2 -48
  40. package/connectors/datadog/src/tools.ts +48 -0
  41. package/connectors/dataprofile/src/profile.ts +16 -46
  42. package/connectors/dbt/src/server.ts +2 -92
  43. package/connectors/dbt/src/tools.ts +80 -0
  44. package/connectors/dependencytrack/src/server.ts +2 -76
  45. package/connectors/dependencytrack/src/tools.ts +65 -0
  46. package/connectors/discord/src/server.ts +2 -85
  47. package/connectors/discord/src/tools.ts +82 -0
  48. package/connectors/elasticsearch/src/tools.ts +7 -37
  49. package/connectors/fastmail/src/jmap-client.ts +202 -0
  50. package/connectors/fastmail/src/jmap-core.ts +8 -21
  51. package/connectors/fastmail/src/server.ts +2 -187
  52. package/connectors/fastmail/src/tools.ts +4 -23
  53. package/connectors/figma/src/search-filter.ts +2 -11
  54. package/connectors/figma/src/server.ts +2 -113
  55. package/connectors/figma/src/tools.ts +99 -0
  56. package/connectors/firebase/src/server.ts +2 -90
  57. package/connectors/firebase/src/token.ts +2 -5
  58. package/connectors/firebase/src/tools.ts +106 -0
  59. package/connectors/flagsmith/src/server.ts +2 -98
  60. package/connectors/flagsmith/src/tools.ts +95 -0
  61. package/connectors/flux/src/server.ts +52 -53
  62. package/connectors/gcp/src/server.ts +2 -132
  63. package/connectors/gcp/src/tools.ts +123 -0
  64. package/connectors/github/src/server.ts +2 -328
  65. package/connectors/github/src/tools.ts +311 -0
  66. package/connectors/github-actions/src/server.ts +2 -205
  67. package/connectors/github-actions/src/tools.ts +209 -0
  68. package/connectors/gitlab/src/server.ts +2 -384
  69. package/connectors/gitlab/src/tools.ts +392 -0
  70. package/connectors/gmail/src/server.ts +2 -270
  71. package/connectors/gmail/src/tools.ts +209 -0
  72. package/connectors/google-drive/src/server.ts +2 -524
  73. package/connectors/google-drive/src/tools.ts +529 -0
  74. package/connectors/google-meet/src/server.ts +2 -82
  75. package/connectors/google-meet/src/tools.ts +87 -0
  76. package/connectors/google-photos/src/server.ts +2 -132
  77. package/connectors/google-photos/src/tools.ts +138 -0
  78. package/connectors/grafana/src/server.ts +2 -53
  79. package/connectors/grafana/src/tools.ts +53 -0
  80. package/connectors/great-expectations/src/gx-parse.ts +14 -45
  81. package/connectors/greenhouse/src/server.ts +2 -63
  82. package/connectors/greenhouse/src/tools.ts +68 -0
  83. package/connectors/hubspot/src/server.ts +2 -77
  84. package/connectors/hubspot/src/tools.ts +70 -0
  85. package/connectors/iac/src/server.ts +2 -137
  86. package/connectors/iac/src/tools.ts +153 -0
  87. package/connectors/imap/src/imap-core.ts +29 -82
  88. package/connectors/imap/src/server.ts +33 -220
  89. package/connectors/intercom/src/server.ts +2 -61
  90. package/connectors/intercom/src/tools.ts +58 -0
  91. package/connectors/jenkins/src/jenkins-api.ts +2 -6
  92. package/connectors/jenkins/src/server.ts +2 -215
  93. package/connectors/jenkins/src/tools.ts +240 -0
  94. package/connectors/jira/src/server.ts +2 -301
  95. package/connectors/jira/src/tools.ts +325 -0
  96. package/connectors/kubernetes/src/server.ts +2 -155
  97. package/connectors/kubernetes/src/tools.ts +160 -0
  98. package/connectors/launchdarkly/src/server.ts +2 -75
  99. package/connectors/launchdarkly/src/tools.ts +72 -0
  100. package/connectors/lever/src/search-filter.ts +4 -12
  101. package/connectors/lever/src/server.ts +2 -60
  102. package/connectors/lever/src/tools.ts +61 -0
  103. package/connectors/linear/src/server.ts +2 -459
  104. package/connectors/linear/src/tools.ts +482 -0
  105. package/connectors/localdb/src/sql-scan.ts +13 -41
  106. package/connectors/localdb/src/tools.ts +1 -1
  107. package/connectors/looker/src/search-filter.ts +2 -15
  108. package/connectors/looker/src/server.ts +35 -70
  109. package/connectors/mendeley/src/server.ts +2 -55
  110. package/connectors/mendeley/src/tools.ts +54 -0
  111. package/connectors/mercury/src/server.ts +2 -57
  112. package/connectors/mercury/src/tools.ts +50 -0
  113. package/connectors/metabase/src/search-filter.ts +4 -15
  114. package/connectors/metabase/src/server.ts +2 -79
  115. package/connectors/metabase/src/tools.ts +64 -0
  116. package/connectors/miro/src/server.ts +2 -72
  117. package/connectors/miro/src/tools.ts +65 -0
  118. package/connectors/mlflow/src/search-filter.ts +3 -7
  119. package/connectors/mlflow/src/server.ts +20 -40
  120. package/connectors/monte-carlo/src/server.ts +50 -57
  121. package/connectors/netlify/src/search-filter.ts +6 -14
  122. package/connectors/netlify/src/server.ts +2 -60
  123. package/connectors/netlify/src/tools.ts +53 -0
  124. package/connectors/newrelic/src/server.ts +2 -40
  125. package/connectors/newrelic/src/tools.ts +37 -0
  126. package/connectors/notion/src/server.ts +2 -335
  127. package/connectors/notion/src/tools.ts +344 -0
  128. package/connectors/obsidian/src/server.ts +2 -425
  129. package/connectors/obsidian/src/tools.ts +466 -0
  130. package/connectors/onedrive/src/server.ts +2 -249
  131. package/connectors/onedrive/src/tools.ts +253 -0
  132. package/connectors/outlook/src/server.ts +2 -351
  133. package/connectors/outlook/src/tools.ts +357 -0
  134. package/connectors/pagerduty/src/server.ts +2 -132
  135. package/connectors/pagerduty/src/tools.ts +126 -0
  136. package/connectors/pipedrive/src/server.ts +2 -58
  137. package/connectors/pipedrive/src/tools.ts +63 -0
  138. package/connectors/powerbi/src/search-filter.ts +2 -15
  139. package/connectors/powerbi/src/server.ts +43 -68
  140. package/connectors/prefect/src/search-filter.ts +3 -16
  141. package/connectors/prefect/src/server.ts +2 -90
  142. package/connectors/prefect/src/tools.ts +83 -0
  143. package/connectors/protonmail/src/mail-core.ts +16 -75
  144. package/connectors/protonmail/src/server.ts +34 -214
  145. package/connectors/raindrop/src/server.ts +2 -103
  146. package/connectors/raindrop/src/tools.ts +103 -0
  147. package/connectors/ramp/src/server.ts +2 -105
  148. package/connectors/ramp/src/tools.ts +98 -0
  149. package/connectors/readwise/src/server.ts +2 -88
  150. package/connectors/readwise/src/tools.ts +81 -0
  151. package/connectors/sagemaker/src/tools.ts +21 -40
  152. package/connectors/salesforce/src/search-filter.ts +4 -11
  153. package/connectors/salesforce/src/server.ts +2 -90
  154. package/connectors/salesforce/src/tools.ts +75 -0
  155. package/connectors/semgrep/src/search-filter.ts +3 -10
  156. package/connectors/semgrep/src/server.ts +2 -104
  157. package/connectors/semgrep/src/tools.ts +92 -0
  158. package/connectors/sentry/src/server.ts +2 -58
  159. package/connectors/sentry/src/tools.ts +53 -0
  160. package/connectors/slack/src/server.ts +2 -352
  161. package/connectors/slack/src/tools.ts +376 -0
  162. package/connectors/snowflake/src/search-filter.ts +4 -19
  163. package/connectors/snowflake/src/server.ts +6 -25
  164. package/connectors/snyk/src/server.ts +2 -140
  165. package/connectors/snyk/src/tools.ts +136 -0
  166. package/connectors/sonarqube/src/server.ts +2 -111
  167. package/connectors/sonarqube/src/tools.ts +98 -0
  168. package/connectors/stackoverflow/src/server.ts +2 -77
  169. package/connectors/stackoverflow/src/tools.ts +66 -0
  170. package/connectors/storybook/src/storybook-parse.ts +3 -6
  171. package/connectors/stripe/src/server.ts +2 -61
  172. package/connectors/stripe/src/tools.ts +54 -0
  173. package/connectors/superset/src/search-filter.ts +4 -15
  174. package/connectors/superset/src/server.ts +2 -115
  175. package/connectors/superset/src/tools.ts +98 -0
  176. package/connectors/tableau/src/search-filter.ts +2 -15
  177. package/connectors/tableau/src/server.ts +13 -65
  178. package/connectors/teams/src/server.ts +2 -249
  179. package/connectors/teams/src/tools.ts +283 -0
  180. package/connectors/testflight/src/server.ts +2 -70
  181. package/connectors/testflight/src/tools.ts +79 -0
  182. package/connectors/vercel/src/search-filter.ts +2 -10
  183. package/connectors/vercel/src/server.ts +2 -75
  184. package/connectors/vercel/src/tools.ts +73 -0
  185. package/connectors/vertex-ai/src/tools.ts +23 -33
  186. package/connectors/wiz/src/search-filter.ts +6 -30
  187. package/connectors/wiz/src/server.ts +2 -188
  188. package/connectors/wiz/src/tools.ts +168 -0
  189. package/connectors/zendesk/src/server.ts +2 -73
  190. package/connectors/zendesk/src/tools.ts +60 -0
  191. package/connectors/zoom/src/search-filter.ts +1 -7
  192. package/connectors/zoom/src/server.ts +2 -94
  193. package/connectors/zoom/src/tools.ts +93 -0
  194. package/connectors/zotero/src/server.ts +2 -78
  195. package/connectors/zotero/src/tools.ts +67 -0
  196. package/package.json +14 -12
  197. package/shared/access-token-cache.ts +123 -0
  198. package/shared/atlassian-json-fetch.ts +6 -16
  199. package/shared/audit-chain.ts +601 -34
  200. package/shared/cli-json-kit.ts +191 -0
  201. package/shared/collection-tool-kit.ts +143 -0
  202. package/shared/connector-mode.ts +3 -2
  203. package/shared/consent-kit.ts +81 -26
  204. package/shared/cursor-list-tool.ts +29 -0
  205. package/shared/env-json-api.ts +209 -0
  206. package/shared/fetch-json-text.ts +27 -0
  207. package/shared/github-rest.ts +62 -0
  208. package/shared/graphql-json.ts +60 -0
  209. package/shared/imap-mail-core.ts +91 -0
  210. package/shared/imap-tool-kit.ts +57 -23
  211. package/shared/imapflow-adapter.ts +417 -0
  212. package/shared/nimbus-spawn.ts +84 -27
  213. package/shared/rest-tool-kit.ts +77 -7
  214. package/shared/run-cli-json.ts +38 -16
  215. package/shared/safe-cli-arg.ts +139 -17
  216. package/shared/search-filter.ts +58 -18
  217. package/shared/walk-files.ts +118 -0
  218. package/shared/windows-batch-args.ts +273 -0
  219. package/shared/write-budget.ts +100 -0
  220. package/standalone/src/launcher.ts +19 -7
@@ -0,0 +1,273 @@
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 — and
32
+ * a CloudFormation template body, whenever the `aws` that runs is a batch file: a pip-installed v1
33
+ * found on `PATH` before any `aws.exe`.
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` — today Bun reads no other, and a bare `az` with only `Path` set
187
+ * does not start at all — and each is searched both as written and with whitespace and quotes
188
+ * stripped from its entries; a name written with a `/` is looked up in its directory as one with
189
+ * a `\` is; and the first place holding the name answers yes if it holds a `.cmd` or `.bat` by
190
+ * that name, even beside an `.exe` Bun would try first.
191
+ */
192
+ export function mayRunAsBatchFile(
193
+ bin: string,
194
+ env: Record<string, string | undefined>,
195
+ probe: BatchFileProbe = {},
196
+ ): boolean {
197
+ if (isBatchFileName(bin)) {
198
+ return true;
199
+ }
200
+ if (bin === "" || endsWithLookupExtension(bin)) {
201
+ return false;
202
+ }
203
+ const paths = pathValues(env);
204
+ if (paths.length === 0) {
205
+ return false;
206
+ }
207
+ const exists = probe.exists ?? isFile;
208
+ const cwd = probe.cwd ?? process.cwd();
209
+ if (bin.includes("/") || bin.includes("\\")) {
210
+ return firstHolderIsBatch([win32.resolve(cwd, bin)], exists);
211
+ }
212
+ return paths.some((value) =>
213
+ searchOrders(value).some((dirs) =>
214
+ firstHolderIsBatch(
215
+ dirs.map((dir) => win32.resolve(cwd, dir, bin)),
216
+ exists,
217
+ ),
218
+ ),
219
+ );
220
+ }
221
+
222
+ /** A character named the way a refusal message can show it. */
223
+ function describeCharacter(ch: string): string {
224
+ const code = ch.codePointAt(0) ?? 0;
225
+ return code < 0x20
226
+ ? `the control character U+${code.toString(16).toUpperCase().padStart(4, "0")}`
227
+ : JSON.stringify(ch);
228
+ }
229
+
230
+ /** Options for {@link batchArgumentRefusal}: the filesystem probe, and the platform it applies on. */
231
+ export interface BatchArgumentOptions extends BatchFileProbe {
232
+ /** Defaults to `process.platform`. The rule applies only on `"win32"`. */
233
+ readonly platform?: NodeJS.Platform;
234
+ }
235
+
236
+ /**
237
+ * Why `command` must not be spawned with `env`, or `undefined` when it may: on Windows, when the
238
+ * program may start a batch file ({@link mayRunAsBatchFile}) and an argument holds a character
239
+ * cmd.exe would act on ({@link cmdMetacharacterIn}).
240
+ *
241
+ * The arguments are scanned first, so the filesystem is consulted only for a command that would
242
+ * be refused if its program were a batch file — an ordinary spawn costs no lookups at all. The
243
+ * message names the argument by position and the character, never the value, which may be long
244
+ * or private.
245
+ */
246
+ export function batchArgumentRefusal(
247
+ command: readonly string[],
248
+ env: Record<string, string | undefined>,
249
+ options: BatchArgumentOptions = {},
250
+ ): string | undefined {
251
+ if ((options.platform ?? process.platform) !== "win32") {
252
+ return undefined;
253
+ }
254
+ const [bin, ...args] = command;
255
+ if (bin === undefined) {
256
+ return undefined;
257
+ }
258
+ for (const [index, arg] of args.entries()) {
259
+ const ch = cmdMetacharacterIn(arg);
260
+ if (ch === undefined) {
261
+ continue;
262
+ }
263
+ if (!mayRunAsBatchFile(bin, env, options)) {
264
+ return undefined;
265
+ }
266
+ return (
267
+ `refused to run ${JSON.stringify(bin)}: it may start a Windows batch file, which cmd.exe ` +
268
+ `parses again before the CLI sees its arguments, and argument ${String(index + 1)} holds ` +
269
+ `${describeCharacter(ch)}, which cmd.exe would act on. Nothing was run.`
270
+ );
271
+ }
272
+ return undefined;
273
+ }
@@ -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
+ }
@@ -10,7 +10,10 @@ const ID_RE = /^[a-z0-9-]+$/;
10
10
  *
11
11
  * A registration CALL, or the registrar handed to a shared kit — not a bare substring, which the
12
12
  * registrar's own `const registerWriteTool = ...` would satisfy even with nothing registered.
13
- * Kept in step with the twin in check-connector-consent.ts.
13
+ *
14
+ * The ONE copy. `audit:connector-consent` (scripts/check-connector-consent.ts) imports it rather
15
+ * than keeping a twin, so the launcher's runtime verdict and the audit's static one cannot drift.
16
+ * They had: the audit's twin learned to tolerate a carriage return (below) and this copy never did.
14
17
  *
15
18
  * Deliberately NOT a regular expression. The previous pattern was
16
19
  * `^\s*register[A-Za-z]*WriteTool\(` under `/m`, and it drew two rounds of ReDoS reports. The
@@ -25,9 +28,15 @@ const ID_RE = /^[a-z0-9-]+$/;
25
28
  * bounded work, no star sits next to an overlapping literal, and the accepted language is
26
29
  * unchanged — including the trailing-comma form, which still requires the comma to end the line.
27
30
  */
28
- function registersWriteTool(src: string): boolean {
31
+ export function registersWriteTool(src: string): boolean {
29
32
  for (const line of src.split("\n")) {
30
- const t = line.trimStart();
33
+ // trim(), not trimStart(): the equality below is exact, and a CRLF checkout leaves a trailing
34
+ // carriage return that breaks it. Observed in the consent audit on the first run of this repo
35
+ // on its own, before .gitattributes existed: it reported imap and protonmail as declaring
36
+ // ungated writes while both register through the kit, on a line reading `registerWriteTool,`
37
+ // plus a CR. That failed SAFE (a false finding there, a false refusal here — never a false
38
+ // green), but a verdict that depends on the checkout's line endings is waiting to be wrong.
39
+ const t = line.trim();
31
40
  if (t === "registerWriteTool,") return true;
32
41
  if (!t.startsWith("register")) continue;
33
42
  const at = t.indexOf("WriteTool(");
@@ -57,13 +66,13 @@ function connectorsDir(): string {
57
66
  * The id is validated against a strict allow-list BEFORE being joined into a path. A separator or
58
67
  * `..` would otherwise let the id escape the connectors directory and import an arbitrary module.
59
68
  */
60
- export function resolveConnectorEntry(id: string): string {
69
+ export function resolveConnectorEntry(id: string, root: string = connectorsDir()): string {
61
70
  if (!ID_RE.test(id)) {
62
71
  throw new Error(
63
72
  `invalid connector id ${JSON.stringify(id)}: expected only lowercase letters, digits and hyphens`,
64
73
  );
65
74
  }
66
- return join(connectorsDir(), id, "src", "server.ts");
75
+ return join(root, id, "src", "server.ts");
67
76
  }
68
77
 
69
78
  export type Eligibility =
@@ -154,6 +163,9 @@ export type ConnectorImporter = (entry: string) => Promise<{
154
163
  export async function runStandalone(
155
164
  argv: readonly string[],
156
165
  importConnector: ConnectorImporter = (entry) => import(entry),
166
+ // Injectable for the same reason as standaloneEligibility's root: every real connector is
167
+ // migrated, so only a fixture can show the refusal path end to end.
168
+ root: string = connectorsDir(),
157
169
  ): Promise<number> {
158
170
  const id = argv[0];
159
171
  if (id === undefined) {
@@ -162,7 +174,7 @@ export async function runStandalone(
162
174
  }
163
175
  let entry: string;
164
176
  try {
165
- entry = resolveConnectorEntry(id);
177
+ entry = resolveConnectorEntry(id, root);
166
178
  } catch (e) {
167
179
  process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
168
180
  return 2;
@@ -172,7 +184,7 @@ export async function runStandalone(
172
184
  return 2;
173
185
  }
174
186
 
175
- const verdict = standaloneEligibility(id);
187
+ const verdict = standaloneEligibility(id, root);
176
188
  if (!verdict.eligible) {
177
189
  process.stderr.write(`${verdict.reason}\n`);
178
190
  return 3;