@nimbus-dev/connectors 0.2.0 → 0.2.2

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 (215) hide show
  1. package/README.md +18 -4
  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 +15 -34
  12. package/connectors/aws/src/server.ts +2 -183
  13. package/connectors/aws/src/tools.ts +197 -0
  14. package/connectors/azure/src/server.ts +2 -131
  15. package/connectors/azure/src/tools.ts +133 -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 +14 -27
  29. package/connectors/cloudwatch/src/tools.ts +15 -34
  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 +152 -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 +159 -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 +19 -38
  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 +15 -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 +13 -6
  200. package/shared/cli-json-kit.ts +150 -0
  201. package/shared/collection-tool-kit.ts +143 -0
  202. package/shared/connector-mode.ts +3 -2
  203. package/shared/cursor-list-tool.ts +29 -0
  204. package/shared/env-json-api.ts +209 -0
  205. package/shared/fetch-json-text.ts +27 -0
  206. package/shared/github-rest.ts +62 -0
  207. package/shared/graphql-json.ts +60 -0
  208. package/shared/imap-mail-core.ts +91 -0
  209. package/shared/imap-tool-kit.ts +57 -23
  210. package/shared/imapflow-adapter.ts +417 -0
  211. package/shared/rest-tool-kit.ts +77 -7
  212. package/shared/run-cli-json.ts +38 -16
  213. package/shared/search-filter.ts +58 -18
  214. package/shared/walk-files.ts +118 -0
  215. package/standalone/src/launcher.ts +19 -7
@@ -1,18 +1,88 @@
1
1
  /**
2
- * rest-tool-kit — re-export of the shared REST fetch wrapper for Bearer-auth
3
- * connectors, now owned by `@nimbus-dev/sdk/connector-kit`.
2
+ * rest-tool-kit — the REST tool plumbing for token-auth connectors.
4
3
  *
5
- * These helpers originated here and were upstreamed to the SDK in 1.11.0. The
6
- * SDK is now the single owner; this module stays as the connector-facing import
7
- * path so the ~99 connectors keep their existing relative imports.
4
+ * `makeRestFetcher` and `makeRestToolRegistrar` originated here and were upstreamed
5
+ * to the SDK in 1.11.0. The SDK is now their single owner; this module re-exports
6
+ * them so the ~99 connectors keep their existing relative imports. Named
7
+ * re-exports, deliberately not `export *` — see the note in `mcp-tool-kit.ts`.
8
8
  *
9
- * Named re-exports, deliberately not `export *` — see the note in
10
- * `mcp-tool-kit.ts`.
9
+ * The two helpers defined BELOW are this repository's own and stay here:
10
+ * {@link makeRestWriteToolRegistrar} registers through the consent kit, which the
11
+ * SDK does not have, and {@link toRestFetchResult} is the tail of the connector
12
+ * fetches whose credential is not a Bearer token, which the SDK's fetch cannot send.
11
13
  */
12
14
 
15
+ import {
16
+ type HttpJsonBodyResponse,
17
+ mcpJsonResultIfOk,
18
+ type RestFetchResult,
19
+ requireProcessEnv,
20
+ type ZodObjectSchema,
21
+ } from "@nimbus-dev/sdk/connector-kit";
22
+ import type { WriteToolConfig, WriteToolRegistrar } from "./consent-kit.ts";
23
+
13
24
  export type {
14
25
  RestFetcherConfig,
15
26
  RestFetchResult,
16
27
  RestToolRegistrar,
17
28
  } from "@nimbus-dev/sdk/connector-kit";
18
29
  export { makeRestFetcher, makeRestToolRegistrar } from "@nimbus-dev/sdk/connector-kit";
30
+
31
+ /** The per-connector registrar {@link makeRestWriteToolRegistrar} builds. */
32
+ export type RestWriteToolRegistrar = <T>(
33
+ name: string,
34
+ cfg: WriteToolConfig<T>,
35
+ description: string,
36
+ schema: ZodObjectSchema<T>,
37
+ buildPath: (args: T) => string,
38
+ buildInit?: (args: T) => RequestInit,
39
+ ) => void;
40
+
41
+ /**
42
+ * The write twin of `makeRestToolRegistrar`: the same standard tool body —
43
+ * `token = requireProcessEnv(<env>)`, `res = await <fetch>(token, buildPath(args)
44
+ * [, buildInit(args)])`, `return mcpJsonResultIfOk(<label>, res, <snippetMax>)` —
45
+ * registered through the connector's consent-kit write registrar instead of its
46
+ * read-only one, so every mutation it registers is gated.
47
+ *
48
+ * The connector passes its OWN `registerWriteTool` in rather than this kit building
49
+ * one: the standalone launcher decides whether a connector's mutations are gated by
50
+ * reading that connector's own `server.ts`/`tools.ts`, and a registrar constructed in
51
+ * here would be invisible to it.
52
+ */
53
+ export function makeRestWriteToolRegistrar(cfg: {
54
+ readonly registerWriteTool: WriteToolRegistrar;
55
+ readonly tokenEnv: string;
56
+ readonly serviceLabel: string;
57
+ readonly fetch: (
58
+ token: string,
59
+ pathOrUrl: string,
60
+ init?: RequestInit,
61
+ ) => Promise<HttpJsonBodyResponse>;
62
+ /** Body-snippet length for the `<label> <status>: <snippet>` error (mcpJsonResultIfOk default 300). */
63
+ readonly snippetMax?: number;
64
+ }): RestWriteToolRegistrar {
65
+ return (name, toolCfg, description, schema, buildPath, buildInit) => {
66
+ cfg.registerWriteTool(name, toolCfg, description, schema, async (args) => {
67
+ const token = requireProcessEnv(cfg.tokenEnv);
68
+ const res = await cfg.fetch(token, buildPath(args), buildInit?.(args));
69
+ return mcpJsonResultIfOk(cfg.serviceLabel, res, cfg.snippetMax);
70
+ });
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Read a response into the {@link RestFetchResult} shape a REST tool's fetch
76
+ * returns. `json` is the parsed body, or `null` when the body is not JSON (an empty
77
+ * 204, an HTML error page): what that means is the tool's decision, not this one's.
78
+ */
79
+ export async function toRestFetchResult(res: Response): Promise<RestFetchResult> {
80
+ const text = await res.text();
81
+ let json: unknown;
82
+ try {
83
+ json = JSON.parse(text) as unknown;
84
+ } catch {
85
+ json = null;
86
+ }
87
+ return { ok: res.ok, status: res.status, json, text };
88
+ }
@@ -2,23 +2,36 @@ import { nimbusSpawn } from "./nimbus-spawn.ts";
2
2
 
3
3
  export type RunCliJsonResult = { ok: true; data: unknown } | { ok: false; message: string };
4
4
 
5
- export async function runCliOk(
5
+ /**
6
+ * Spawn `command`. An empty command, or a non-zero exit, becomes the one failure message every
7
+ * caller reports: `<binary> exited <code>: <first 500 chars of stderr>`.
8
+ */
9
+ async function spawnChecked(
6
10
  command: readonly string[],
7
11
  env: Record<string, string | undefined>,
8
- ): Promise<{ ok: true } | { ok: false; message: string }> {
12
+ ): Promise<{ ok: true; stdout: string } | { ok: false; message: string }> {
9
13
  if (command.length === 0) {
10
14
  return { ok: false, message: "empty command" };
11
15
  }
12
- const { code, stderr: err } = await nimbusSpawn(command, env);
16
+ const { code, stdout, stderr } = await nimbusSpawn(command, env);
13
17
  if (code !== 0) {
14
18
  return {
15
19
  ok: false,
16
- message: `${command[0] ?? "cli"} exited ${String(code)}: ${err.slice(0, 500)}`,
20
+ message: `${command[0] ?? "cli"} exited ${String(code)}: ${stderr.slice(0, 500)}`,
17
21
  };
18
22
  }
19
- return { ok: true };
23
+ return { ok: true, stdout };
24
+ }
25
+
26
+ export async function runCliOk(
27
+ command: readonly string[],
28
+ env: Record<string, string | undefined>,
29
+ ): Promise<{ ok: true } | { ok: false; message: string }> {
30
+ const r = await spawnChecked(command, env);
31
+ return r.ok ? { ok: true } : r;
20
32
  }
21
33
 
34
+ /** {@link runCliOk}, throwing its failure message instead of returning it. */
22
35
  export async function runCliOkThrowing(
23
36
  command: readonly string[],
24
37
  env: Record<string, string | undefined>,
@@ -33,23 +46,32 @@ export async function runCliJson(
33
46
  command: readonly string[],
34
47
  env: Record<string, string | undefined>,
35
48
  ): Promise<RunCliJsonResult> {
36
- if (command.length === 0) {
37
- return { ok: false, message: "empty command" };
38
- }
39
- const { code, stdout: out, stderr: err } = await nimbusSpawn(command, env);
40
- if (code !== 0) {
41
- return {
42
- ok: false,
43
- message: `${command[0] ?? "cli"} exited ${String(code)}: ${err.slice(0, 500)}`,
44
- };
49
+ const r = await spawnChecked(command, env);
50
+ if (!r.ok) {
51
+ return r;
45
52
  }
46
- const trimmed = out.trim();
53
+ const trimmed = r.stdout.trim();
47
54
  if (trimmed === "") {
48
55
  return { ok: true, data: null };
49
56
  }
50
57
  try {
51
58
  return { ok: true, data: JSON.parse(trimmed) as unknown };
52
59
  } catch {
53
- return { ok: false, message: `invalid JSON from CLI: ${out.slice(0, 200)}` };
60
+ return { ok: false, message: `invalid JSON from CLI: ${r.stdout.slice(0, 200)}` };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * {@link runCliJson}, throwing its failure message instead of returning it. Resolves to the parsed
66
+ * JSON, or `null` when the command printed nothing.
67
+ */
68
+ export async function runCliJsonThrowing(
69
+ command: readonly string[],
70
+ env: Record<string, string | undefined>,
71
+ ): Promise<unknown> {
72
+ const r = await runCliJson(command, env);
73
+ if (!r.ok) {
74
+ throw new Error(r.message);
54
75
  }
76
+ return r.data;
55
77
  }
@@ -43,21 +43,35 @@ export function tagText(row: Record<string, unknown>): string {
43
43
  }
44
44
 
45
45
  /**
46
- * Extract tag names from an array of `{name: string}` tag objects (e.g. Airflow, DependencyTrack).
47
- * Returns "" when `tags` is absent, not an array, or contains no object entries with a string `name`.
46
+ * The non-empty string entries of the array at `row[key]`, space-joined; `""` when it is absent or
47
+ * not an array (Airflow `owners`, Prefect `tags`). Unlike {@link tagText}, an empty-string entry is
48
+ * skipped rather than joined — which shows in the haystack's spacing, so it is a separate rule.
48
49
  */
49
- export function tagNamesFromObjects(row: Record<string, unknown>): string {
50
- const tags = row["tags"];
51
- if (!Array.isArray(tags)) {
50
+ export function nonEmptyStringsText(row: Record<string, unknown>, key: string): string {
51
+ const list = row[key];
52
+ if (!Array.isArray(list)) {
53
+ return "";
54
+ }
55
+ return list.filter((v): v is string => typeof v === "string" && v !== "").join(" ");
56
+ }
57
+
58
+ /**
59
+ * The non-empty string `name` of each objectish entry of the array at `row[key]`, space-joined
60
+ * (Wiz `projects`). Returns "" when `row[key]` is absent, not an array, or holds no object entry
61
+ * with a non-empty string `name`.
62
+ */
63
+ export function objectNamesText(row: Record<string, unknown>, key: string): string {
64
+ const list = row[key];
65
+ if (!Array.isArray(list)) {
52
66
  return "";
53
67
  }
54
68
  const names: string[] = [];
55
- for (const t of tags) {
56
- const tag = asObjectish(t);
57
- if (tag === undefined) {
69
+ for (const entry of list) {
70
+ const obj = asObjectish(entry);
71
+ if (obj === undefined) {
58
72
  continue;
59
73
  }
60
- const name = tag["name"];
74
+ const name = obj["name"];
61
75
  if (typeof name === "string" && name !== "") {
62
76
  names.push(name);
63
77
  }
@@ -65,6 +79,14 @@ export function tagNamesFromObjects(row: Record<string, unknown>): string {
65
79
  return names.join(" ");
66
80
  }
67
81
 
82
+ /**
83
+ * Extract tag names from an array of `{name: string}` tag objects (e.g. Airflow, DependencyTrack).
84
+ * Returns "" when `tags` is absent, not an array, or contains no object entries with a string `name`.
85
+ */
86
+ export function tagNamesFromObjects(row: Record<string, unknown>): string {
87
+ return objectNamesText(row, "tags");
88
+ }
89
+
68
90
  export function filterByQuery<T>(items: readonly T[], options: FilterByQueryOptions<T>): T[] {
69
91
  const needle = options.query.toLowerCase();
70
92
  const cap = options.limit ?? 50;
@@ -88,28 +110,46 @@ export function filterByQuery<T>(items: readonly T[], options: FilterByQueryOpti
88
110
 
89
111
  export type FieldExtractor = (item: unknown) => readonly (string | null | undefined)[] | null;
90
112
 
91
- /**
92
- * Build a {@link FieldExtractor} that reads a fixed list of string keys off each
93
- * objectish row, optionally appending the standard `tags` text. Collapses the
94
- * boilerplate `fieldsOf` body shared by the simpler connectors.
95
- */
96
- export function fieldsFromKeys(
113
+ /** The one body behind {@link fieldsFromKeys} and {@link recordFieldsFromKeys}. */
114
+ function keyedFields(
115
+ toRow: (item: unknown) => Record<string, unknown> | undefined,
97
116
  keys: readonly string[],
98
- opts?: { readonly tags?: boolean },
117
+ tags: boolean,
99
118
  ): FieldExtractor {
100
119
  return (item: unknown) => {
101
- const row = asObjectish(item);
120
+ const row = toRow(item);
102
121
  if (row === undefined) {
103
122
  return null;
104
123
  }
105
124
  const parts = keys.map((key) => stringField(row, key));
106
- if (opts?.tags === true) {
125
+ if (tags) {
107
126
  parts.push(tagText(row));
108
127
  }
109
128
  return parts;
110
129
  };
111
130
  }
112
131
 
132
+ /**
133
+ * Build a {@link FieldExtractor} that reads a fixed list of string keys off each
134
+ * objectish row, optionally appending the standard `tags` text. Collapses the
135
+ * boilerplate `fieldsOf` body shared by the simpler connectors.
136
+ */
137
+ export function fieldsFromKeys(
138
+ keys: readonly string[],
139
+ opts?: { readonly tags?: boolean },
140
+ ): FieldExtractor {
141
+ return keyedFields(asObjectish, keys, opts?.tags === true);
142
+ }
143
+
144
+ /**
145
+ * {@link fieldsFromKeys} for rows that must be plain objects: an ARRAY row is skipped
146
+ * ({@link asRecord}) instead of being read as an object that has none of the keys — which a
147
+ * query made only of spaces would still match. The BI connectors' filters use this rule.
148
+ */
149
+ export function recordFieldsFromKeys(keys: readonly string[]): FieldExtractor {
150
+ return keyedFields(asRecord, keys, false);
151
+ }
152
+
113
153
  /**
114
154
  * Read a nested string field by key path off an objectish row, returning `""`
115
155
  * when any path segment is missing or the leaf is not a string. Shared by the
@@ -0,0 +1,118 @@
1
+ /**
2
+ * walk-files — the bounded recursive directory walk the local-filesystem
3
+ * connectors share, and the path-traversal guard that goes with it.
4
+ *
5
+ * `great-expectations`, `localdb` and `dataprofile` each scan a configured
6
+ * directory tree for the files they care about (`*.json`, `*.sql`, and a set of
7
+ * tabular extensions), and each had written out the same walk: recurse, stop at
8
+ * a depth limit, stop at a file-count limit, swallow an unreadable directory
9
+ * rather than aborting the scan.
10
+ *
11
+ * Those two limits are why this is worth sharing rather than tolerating. They
12
+ * are the guard against a connector being pointed — by configuration or by a
13
+ * symlink — at a tree deep or wide enough to exhaust the process, and a guard
14
+ * maintained in three places is a guard that can be tightened in one and left
15
+ * alone in the other two. They were already inconsistent: 1000 files for
16
+ * great-expectations against 2000 for the others. That difference is real (a
17
+ * GX run directory is small) so it stays a per-caller argument rather than
18
+ * being averaged away — but it is now visibly an argument rather than three
19
+ * independent constants that happen to differ.
20
+ *
21
+ * The same three connectors each carried a copy of {@link assertWithinDir} too,
22
+ * identical but for the directory named in the error — a second security
23
+ * control in three hand-written copies, shared for the same reason.
24
+ */
25
+
26
+ import type { Dirent } from "node:fs";
27
+ import { readdir } from "node:fs/promises";
28
+ import { isAbsolute, join, relative } from "node:path";
29
+
30
+ export interface WalkFilesOptions<T> {
31
+ /** Hard cap on results. The walk stops as soon as it is reached. */
32
+ readonly maxFiles: number;
33
+ /** Hard cap on recursion depth, counted from `root` at 0. */
34
+ readonly maxDepth: number;
35
+ /**
36
+ * What to keep for one file entry, or `undefined` to skip it. Receives the
37
+ * directory entry and its full path, so a caller can select on the name, the
38
+ * extension, or anything else it can decide without reading the file.
39
+ */
40
+ readonly select: (entry: Dirent, fullPath: string) => T | undefined;
41
+ }
42
+
43
+ /**
44
+ * Collect what `select` keeps, walking `root` depth-first within the limits.
45
+ *
46
+ * An unreadable directory is SKIPPED rather than fatal: a scan of a
47
+ * user-configured tree meets permission-denied subdirectories routinely, and
48
+ * failing the whole tool call for one of them would make the connector useless
49
+ * on a real machine. A caller that needs to know a directory was unreadable
50
+ * should stat it itself.
51
+ *
52
+ * Symlinks are not followed — `readdir` reports a link as neither a file nor a
53
+ * directory here, so a cycle cannot make the walk diverge.
54
+ */
55
+ export async function walkFiles<T>(root: string, options: WalkFilesOptions<T>): Promise<T[]> {
56
+ const found: T[] = [];
57
+
58
+ async function walk(dir: string, depth: number): Promise<void> {
59
+ if (depth > options.maxDepth || found.length >= options.maxFiles) {
60
+ return;
61
+ }
62
+ let entries: Dirent[];
63
+ try {
64
+ entries = await readdir(dir, { withFileTypes: true });
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const entry of entries) {
69
+ if (found.length >= options.maxFiles) {
70
+ return;
71
+ }
72
+ const full = join(dir, entry.name);
73
+ if (entry.isDirectory()) {
74
+ await walk(full, depth + 1); // NOSONAR S9382: depth-first under one shared maxFiles cap — a subtree must finish before the next starts or the cap stops at a nondeterministic point, and concurrent readdirs over a wide tree are the exhaustion this walk exists to bound.
75
+ } else if (entry.isFile()) {
76
+ const kept = options.select(entry, full);
77
+ if (kept !== undefined) {
78
+ found.push(kept);
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ await walk(root, 0);
85
+ return found;
86
+ }
87
+
88
+ /**
89
+ * A {@link WalkFilesOptions.select} that keeps the full path of every file with
90
+ * one of `extensions`, compared case-insensitively.
91
+ */
92
+ export function byExtension(
93
+ ...extensions: readonly string[]
94
+ ): (e: Dirent, full: string) => string | undefined {
95
+ const wanted = extensions.map((x) => x.toLowerCase());
96
+ return (entry: Dirent, full: string): string | undefined =>
97
+ wanted.some((x) => entry.name.toLowerCase().endsWith(x)) ? full : undefined;
98
+ }
99
+
100
+ /**
101
+ * The path-traversal guard: throws `"path escapes <what>"` unless `candidate` (an
102
+ * absolute, resolved path) is `root` itself or a descendant of it. `what` names the
103
+ * directory for the message, e.g. `"the configured local DB scripts dir"`.
104
+ *
105
+ * A `relative()` that starts with `..` climbed out of `root` (this also covers
106
+ * `..<sep>`); one that is absolute could not be expressed relative to it at all — a
107
+ * different drive on Windows. It fails closed: a child whose own name begins with
108
+ * `..` (such as `..cache`) is refused too.
109
+ */
110
+ export function assertWithinDir(candidate: string, root: string, what: string): void {
111
+ const rel = relative(root, candidate);
112
+ if (rel === "") {
113
+ return;
114
+ }
115
+ if (rel.startsWith("..") || isAbsolute(rel)) {
116
+ throw new Error(`path escapes ${what}`);
117
+ }
118
+ }
@@ -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;