diff --git a/.changeset/mcp-stdio-env-isolation.md b/.changeset/mcp-stdio-env-isolation.md new file mode 100644 index 000000000..f194fbf8c --- /dev/null +++ b/.changeset/mcp-stdio-env-isolation.md @@ -0,0 +1,11 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**Stdio MCP servers no longer inherit executor's full environment** + +A stdio MCP server that declared any `env` at all was spawned with every environment variable this process holds. The MCP SDK already guards against that: it spawns with `{ ...getDefaultEnvironment(), ...serverParams.env }`, where `getDefaultEnvironment()` is a sudo-style safe-list of `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM` and `USER`. Passing `{ ...process.env, ...config.env }` did not add to that safe-list, it overwrote it. In practice, adding one third-party `npx` server went from "this server can see the API key I gave it" to "this server also holds `EXECUTOR_SECRET_KEY`, the key that decrypts every other stored credential, plus `EXECUTOR_AUTH_TOKEN` and `DATABASE_URL`". The leak sat on the `config.env` branch — the branch a credential-bearing integration takes. + +A stdio server now receives the SDK's safe-list, the variables declared on the source config, and one short allowlist of infrastructure variables read from the host: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (both spellings), `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. Those carry no credential, no source config declares them, and a server behind a corporate proxy or an intercepting CA cannot reach anything without them — the same reasoning and the same list `service install` already uses when it bakes a supervised unit's minimal environment. The declared `env` wins on a key collision. On Windows that collision is resolved case-insensitively, because the OS treats `Path` and `PATH` as one variable while a JavaScript spread does not: a declared `http_proxy` now replaces an inherited `HTTP_PROXY` instead of travelling beside it, which would have left the child reading whichever spelling Windows resolved first. + +If a stdio server relied on some other variable arriving from the host, set it explicitly on the source's `env`. That is now the only way anything beyond the lists above reaches a server, and it is the mechanism that already existed for it. diff --git a/e2e/local/fixtures/stdio-mcp-server.mjs b/e2e/local/fixtures/stdio-mcp-server.mjs index 9f57c79a2..cef83abc5 100644 --- a/e2e/local/fixtures/stdio-mcp-server.mjs +++ b/e2e/local/fixtures/stdio-mcp-server.mjs @@ -11,6 +11,12 @@ // It exposes one tool, `echo_tool`, and (when EXECUTOR_E2E_SECRET is set in the // child env) a second `whoami` tool that returns that env value — so a scenario // can prove a per-connection secret env var actually reached the subprocess. +// +// It also reports its own environment the same way: for each variable in +// ENV_PROBES below it advertises a tool ONLY when that variable is present in +// the child's env. Gating the tool NAME rather than returning a value means the +// answer survives the whole discovery path unaltered — a scenario reads it off +// the tools API, and "absent" cannot be confused with "empty string". import { createInterface } from "node:readline"; @@ -38,6 +44,25 @@ if (process.env.EXECUTOR_E2E_SECRET) { }); } +// Each entry advertises `saw_` when its variable reached this process. +// The three cover the three ways a variable can be handed to a stdio server: +// declared on the source, allowlisted infrastructure inherited from the host, +// and — the leak — an unrelated host variable that must never travel. +const ENV_PROBES = [ + { tool: "saw_declared_env", key: "EXECUTOR_E2E_SECRET" }, + { tool: "saw_proxy_env", key: "NO_PROXY" }, + { tool: "saw_host_secret", key: "EXECUTOR_E2E_HOST_ONLY_SECRET" }, +]; + +for (const probe of ENV_PROBES) { + if (process.env[probe.key] === undefined) continue; + TOOLS.push({ + name: probe.tool, + description: `Present only because ${probe.key} is set in this server's environment`, + inputSchema: { type: "object", properties: {} }, + }); +} + const handle = (msg) => { if (msg.method === "initialize") { send({ diff --git a/e2e/local/stdio-mcp.test.ts b/e2e/local/stdio-mcp.test.ts index a8ece4caa..c7b55bdf3 100644 --- a/e2e/local/stdio-mcp.test.ts +++ b/e2e/local/stdio-mcp.test.ts @@ -14,6 +14,14 @@ // stdio add never created one, so the integration landed with zero connections // and zero tools. The fix auto-creates the default connection on add and routes // the env values into the connection's secret store. +// +// The scenario also covers what ELSE reaches that subprocess. A stdio server +// that declared any `env` used to be spawned with `{ ...process.env, ...env }`, +// so it received every variable the daemon holds — including the key that +// decrypts the secret store. `withLocalServer`'s `env` option plants two +// variables on the real daemon process (see DAEMON_ENV) and the fixture reports +// which of them survived the spawn, so the boundary is measured on the product +// path rather than at the call site of the transport. import { fileURLToPath } from "node:url"; import { expect } from "@effect/vitest"; @@ -37,8 +45,26 @@ const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import. // the connection's secret env reached the spawned subprocess. const SECRET = "s3cr3t-from-the-vault"; +// Planted on the `executor web` process itself, so the only way either value +// can reach a stdio subprocess is by being inherited across the spawn. +// +// NO_PROXY stands in for the infrastructure allowlist: it is one of the +// variables a stdio server must keep inheriting, and it is the inert member of +// that list — with no proxy configured alongside it, it changes nothing about +// the daemon's own egress, so planting it cannot perturb the rest of the run. +// +// The sentinel stands in for a real host secret such as EXECUTOR_SECRET_KEY. +// A variable only this scenario sets keeps the assertion honest on a machine +// where the real one happens not to be exported. +const PROXY_VALUE = "stdio-env-e2e.invalid"; +const HOST_ONLY_SENTINEL = "host-secret-that-must-not-reach-a-stdio-server"; +const DAEMON_ENV = { + NO_PROXY: PROXY_VALUE, + EXECUTOR_E2E_HOST_ONLY_SECRET: HOST_ONLY_SENTINEL, +}; + scenario( - "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret", + "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret and the daemon's own environment withheld", // Must stay STRICTLY greater than the boot-URL wait in `withLocalServer` // (currently 240s). When this CI job runs `stdio-mcp.test.ts` alone it always // pays a cold `vite optimizeDeps` boot (no prior file to warm the cache), the @@ -51,132 +77,153 @@ scenario( const cli = yield* Cli; const runDir = yield* RunDir; - yield* withLocalServer(cli, runDir, (server) => - Effect.gen(function* () { - const client = yield* HttpApiClient.make(api, { - baseUrl: new URL("/api", server.origin).toString(), - transformClient: HttpClient.mapRequest((request) => - HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), - ), - }).pipe(Effect.provide(FetchHttpClient.layer)); - - const slug = "e2e-stdio"; - - // Add the stdio server exactly as the desktop/local "Add MCP" flow does, - // including a secret env var the server needs. - yield* client.mcp.addServer({ - payload: { - transport: "stdio", - name: "E2E Stdio", - command: "node", - args: [FIXTURE], - env: { EXECUTOR_E2E_SECRET: SECRET }, - slug, - }, - }); - - // The integration lands in the catalog — the add itself works. - const integrations = yield* client.integrations.list(); - expect( - integrations.map((i) => String(i.slug)), - "the stdio MCP integration is registered", - ).toContain(slug); - - // The add auto-creates the default connection (the v1.5 split makes this - // the thing that drives tool discovery). Pre-fix there were zero. - const connections = yield* client.connections.list({ query: { integration: slug } }); - expect( - connections.map((c) => String(c.name)), - "a default connection was auto-created for the stdio server", - ).toContain("default"); - - // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami` - // appearing proves the connection's secret env reached the subprocess. - const tools = yield* client.tools.list({ query: { integration: slug } }); - const names = tools.map((t) => t.name); - expect(names, "the stdio server's base tool is detected").toContain("echo_tool"); - expect( - names, - "the secret env var reached the spawned subprocess (whoami is gated on it)", - ).toContain("whoami"); - - // "Properly store auth": the secret value is NOT in the integration's - // config blob — only the var NAME is declared there; the value lives on - // the connection (the secret store). - const stored = yield* client.mcp.getServer({ params: { slug } }); - expect( - JSON.stringify(stored?.config ?? {}), - "the secret value is not persisted in the integration config", - ).not.toContain(SECRET); - - // --- The UI path: DECLARE env var names, then provide the secret value - // as a connection credential (what the add form now does). --- - const declSlug = "e2e-stdio-decl"; - yield* client.mcp.addServer({ - payload: { - transport: "stdio", - name: "E2E Stdio Declared", - command: "node", - args: [FIXTURE], - envVars: ["EXECUTOR_E2E_SECRET"], - slug: declSlug, - }, - }); - - // Declaring a secret env var (no value) does NOT auto-connect: the - // secret is still missing, so there are no tools until you connect. - const beforeConns = yield* client.connections.list({ query: { integration: declSlug } }); - expect(beforeConns, "no connection until the secret is provided").toHaveLength(0); - const beforeTools = yield* client.tools.list({ query: { integration: declSlug } }); - expect(beforeTools, "no tools until the secret is provided").toHaveLength(0); - - // Provide the secret as the connection credential (the connect step). - yield* client.connections.create({ - payload: { - owner: "org", - name: ConnectionName.make("default"), - integration: IntegrationSlug.make(declSlug), - template: AuthTemplateSlug.make("env"), - values: { EXECUTOR_E2E_SECRET: SECRET }, - }, - }); - - const declTools = yield* client.tools.list({ query: { integration: declSlug } }); - expect( - declTools.map((t) => t.name), - "connecting with the secret discovers the env-gated tool", - ).toContain("whoami"); - - // --- versionNegotiation "auto" survives the API → config → connector - // path and still reaches a legacy server: the probe gets the fixture's - // method-not-found for `server/discover` (a definitive legacy verdict) - // and falls back to `initialize`. Modern-era acceptance against a real - // legacy-disabled SDK v2 server lives in the plugin's - // stdio-negotiation.test.ts. --- - const autoSlug = "e2e-stdio-auto"; - yield* client.mcp.addServer({ - payload: { - transport: "stdio", - name: "E2E Stdio Auto", - command: "node", - args: [FIXTURE], - versionNegotiation: "auto", - slug: autoSlug, - }, - }); - - const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); - expect( - JSON.stringify(autoStored?.config ?? {}), - "the negotiation mode is persisted on the integration config", - ).toContain('"versionNegotiation":"auto"'); - - const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); - expect( - autoTools.map((t) => t.name), - "auto negotiation falls back to legacy and still discovers tools", - ).toContain("echo_tool"); - }), + yield* withLocalServer( + cli, + runDir, + (server) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(api, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const slug = "e2e-stdio"; + + // Add the stdio server exactly as the desktop/local "Add MCP" flow does, + // including a secret env var the server needs. + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio", + command: "node", + args: [FIXTURE], + env: { EXECUTOR_E2E_SECRET: SECRET }, + slug, + }, + }); + + // The integration lands in the catalog — the add itself works. + const integrations = yield* client.integrations.list(); + expect( + integrations.map((i) => String(i.slug)), + "the stdio MCP integration is registered", + ).toContain(slug); + + // The add auto-creates the default connection (the v1.5 split makes this + // the thing that drives tool discovery). Pre-fix there were zero. + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect( + connections.map((c) => String(c.name)), + "a default connection was auto-created for the stdio server", + ).toContain("default"); + + // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami` + // appearing proves the connection's secret env reached the subprocess. + const tools = yield* client.tools.list({ query: { integration: slug } }); + const names = tools.map((t) => t.name); + expect(names, "the stdio server's base tool is detected").toContain("echo_tool"); + expect( + names, + "the secret env var reached the spawned subprocess (whoami is gated on it)", + ).toContain("whoami"); + + // --- What the daemon's own environment does, and does not, hand to a + // stdio server. This is the branch that leaked: `e2e-stdio` declares an + // `env`, and the declared-env branch used to spawn with the whole of + // `process.env` merged underneath. Each `saw_*` tool exists only because + // the matching variable was present in the CHILD's environment. --- + expect(names, "the declared env var is visible to the server").toContain( + "saw_declared_env", + ); + expect( + names, + "the host's proxy configuration still reaches the server, or every stdio server behind a corporate proxy breaks", + ).toContain("saw_proxy_env"); + expect( + names, + "a host variable the source never declared (EXECUTOR_E2E_HOST_ONLY_SECRET) must not reach the server", + ).not.toContain("saw_host_secret"); + + // "Properly store auth": the secret value is NOT in the integration's + // config blob — only the var NAME is declared there; the value lives on + // the connection (the secret store). + const stored = yield* client.mcp.getServer({ params: { slug } }); + expect( + JSON.stringify(stored?.config ?? {}), + "the secret value is not persisted in the integration config", + ).not.toContain(SECRET); + + // --- The UI path: DECLARE env var names, then provide the secret value + // as a connection credential (what the add form now does). --- + const declSlug = "e2e-stdio-decl"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Declared", + command: "node", + args: [FIXTURE], + envVars: ["EXECUTOR_E2E_SECRET"], + slug: declSlug, + }, + }); + + // Declaring a secret env var (no value) does NOT auto-connect: the + // secret is still missing, so there are no tools until you connect. + const beforeConns = yield* client.connections.list({ query: { integration: declSlug } }); + expect(beforeConns, "no connection until the secret is provided").toHaveLength(0); + const beforeTools = yield* client.tools.list({ query: { integration: declSlug } }); + expect(beforeTools, "no tools until the secret is provided").toHaveLength(0); + + // Provide the secret as the connection credential (the connect step). + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("default"), + integration: IntegrationSlug.make(declSlug), + template: AuthTemplateSlug.make("env"), + values: { EXECUTOR_E2E_SECRET: SECRET }, + }, + }); + + const declTools = yield* client.tools.list({ query: { integration: declSlug } }); + expect( + declTools.map((t) => t.name), + "connecting with the secret discovers the env-gated tool", + ).toContain("whoami"); + + // --- versionNegotiation "auto" survives the API → config → connector + // path and still reaches a legacy server: the probe gets the fixture's + // method-not-found for `server/discover` (a definitive legacy verdict) + // and falls back to `initialize`. Modern-era acceptance against a real + // legacy-disabled SDK v2 server lives in the plugin's + // stdio-negotiation.test.ts. --- + const autoSlug = "e2e-stdio-auto"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Auto", + command: "node", + args: [FIXTURE], + versionNegotiation: "auto", + slug: autoSlug, + }, + }); + + const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); + expect( + JSON.stringify(autoStored?.config ?? {}), + "the negotiation mode is persisted on the integration config", + ).toContain('"versionNegotiation":"auto"'); + + const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); + expect( + autoTools.map((t) => t.name), + "auto negotiation falls back to legacy and still discovers tools", + ).toContain("echo_tool"); + }), + { env: DAEMON_ENV }, ); }), ); diff --git a/packages/plugins/mcp/README.md b/packages/plugins/mcp/README.md index 20c0bb74f..aabbbfc2a 100644 --- a/packages/plugins/mcp/README.md +++ b/packages/plugins/mcp/README.md @@ -18,8 +18,8 @@ import { mcpPlugin } from "@executor-js/plugin-mcp"; const executor = await createExecutor({ onElicitation: "accept-all", - // Stdio integrations spawn a local subprocess and inherit `process.env` — - // only enable for trusted single-user contexts. + // Stdio integrations spawn a local subprocess — only enable for trusted + // single-user contexts. See "Stdio environment" below for what they receive. plugins: [mcpPlugin({ dangerouslyAllowStdioMCP: true })] as const, }); @@ -50,6 +50,10 @@ const result = await executor.tools.invoke("context7.searchLibraries", { }); ``` +## Stdio environment + +A stdio server does not inherit `process.env`. It receives the MCP SDK's safe-list (`HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; the Windows equivalents on Windows), plus a short allowlist of proxy and CA variables — `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, their lowercase spellings, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, and `SSL_CERT_DIR` — so a server behind a corporate proxy can still reach the network. Nothing else travels, so a secret in the host environment stays there. Give the server anything more, an API key above all, through the integration's own `env`, which wins on a key collision. On Windows that collision is resolved case-insensitively, because the OS treats `Path` and `PATH` as one variable. + ## Using with Effect If you're building on `@executor-js/sdk/core` (the raw Effect entry), import this plugin from its `/core` subpath instead — it returns the Effect-shaped plugin with `Effect.Effect<...>`-returning methods rather than promisified wrappers: diff --git a/packages/plugins/mcp/src/sdk/stdio-connector.ts b/packages/plugins/mcp/src/sdk/stdio-connector.ts index 6fec6f061..70bb84b0c 100644 --- a/packages/plugins/mcp/src/sdk/stdio-connector.ts +++ b/packages/plugins/mcp/src/sdk/stdio-connector.ts @@ -14,7 +14,7 @@ // the import and therefore never touch `node:child_process`. // --------------------------------------------------------------------------- -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio"; export type StdioTransportConfig = { readonly command: string; @@ -23,10 +23,115 @@ export type StdioTransportConfig = { readonly cwd?: string; }; +/** + * Host variables a stdio server inherits, on top of the SDK's own safe-list. + * + * Same reasoning and same list as the TLS pass-through `service install` bakes + * into a supervised unit's minimal environment (`apps/cli/src/service.ts`): a + * stdio server sits behind the same corporate proxy and the same intercepting + * CA as the process that spawned it, and those paths commonly live outside the + * OS trust store. Dropping them makes every HTTPS call from every stdio server + * fail on such a network. None of them carries a credential. + * + * Deliberately short and closed. Anything else a server needs — an API key + * above all — is declared on the source config's `env`, which is the mechanism + * that already exists for exactly that, and which wins on a key collision. + */ +const inheritedEnvKeys = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + // Lowercase spellings are not aliases: libcurl and most Unix tooling read + // these, while Node reads the uppercase ones. Both are in real use. + "http_proxy", + "https_proxy", + "no_proxy", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +] as const; + +/** Read at spawn time, not module load: the host's proxy configuration can be + * set after this module is first imported. */ +const inheritedEnv = (): Record => + Object.fromEntries( + inheritedEnvKeys.flatMap((key) => { + const value = process.env[key]; + return value ? [[key, value] as const] : []; + }), + ); + +/** + * Combine the inherited allowlist with the source config's declared `env`, + * using the key identity the target platform actually has. + * + * On Windows an environment key is case-insensitive: `Path` and `PATH` name one + * variable. A JavaScript spread is case-sensitive on every platform, so a plain + * merge can emit two spellings of the same variable — inherited `HTTP_PROXY` + * beside a declared `http_proxy`, or the SDK's `PATH` beside a declared `Path`. + * Both then reach the child's environment block, and the child reads whichever + * Windows resolves first rather than the one the config declared. Precedence + * that reads correctly in the source is silently lost at the boundary. + * + * So on `win32` this merges by case-insensitive key identity. The declared env + * still wins, and the losing spelling is dropped instead of travelling beside + * the winner. A key that collides with one the SDK sets is emitted with the + * SDK's spelling, because the SDK spreads its own safe-list underneath this + * result: matching its spelling replaces that entry, while a different spelling + * would only add an alias next to it. + * + * On every other platform the keys are genuinely case-sensitive, and this is a + * plain merge. + */ +export const mergeStdioEnv = ({ + platform, + inherited, + declared, + sdkKeys = [], +}: { + readonly platform: NodeJS.Platform; + readonly inherited: Record; + readonly declared?: Record; + /** Keys the SDK's own safe-list will place underneath this result. */ + readonly sdkKeys?: ReadonlyArray; +}): Record => { + if (platform !== "win32") return { ...inherited, ...declared }; + + const sdkSpelling = new Map(sdkKeys.map((key) => [key.toLowerCase(), key] as const)); + // Keyed by the case-insensitive identity; insertion order follows first + // sight of a variable, and a later source overwrites the entry in place. + const merged = new Map(); + + for (const source of [inherited, declared]) { + for (const [key, value] of Object.entries(source ?? {})) { + const identity = key.toLowerCase(); + merged.set(identity, [sdkSpelling.get(identity) ?? key, value]); + } + } + + return Object.fromEntries(merged.values()); +}; + export const createStdioTransport = (config: StdioTransportConfig) => new StdioClientTransport({ command: config.command, args: config.args ? [...config.args] : undefined, - env: config.env ? ({ ...process.env, ...config.env } as Record) : undefined, + // Pass the declared env plus the infrastructure allowlist above, and + // nothing else. The SDK merges this over `getDefaultEnvironment()`, a + // sudo-style safe-list (HOME, LOGNAME, PATH, SHELL, TERM, USER) that + // deliberately excludes everything else and skips function-shaped values + // as a security risk. + // + // Spreading `process.env` here did not add to that safe-list, it defeated + // it: the child received every variable this process holds, which for a + // server that spawns one includes `EXECUTOR_SECRET_KEY` (the key that + // decrypts the secret store), `EXECUTOR_AUTH_TOKEN`, `DATABASE_URL` and + // whatever else the operator exported. + env: mergeStdioEnv({ + platform: process.platform, + inherited: inheritedEnv(), + declared: config.env, + sdkKeys: Object.keys(getDefaultEnvironment()), + }), cwd: config.cwd, }); diff --git a/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts b/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts new file mode 100644 index 000000000..61439929c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts @@ -0,0 +1,167 @@ +// What environment does a stdio MCP server actually receive? +// +// This spawns a real subprocess through the real transport and reads back the +// environment that process was handed. Asserting on the arguments we pass to +// the SDK would not answer the question — the SDK merges its own safe-list +// underneath ours, so the only honest answer comes from the child itself. +// +// The child is a plain node script rather than an MCP server: it is spawned by +// the same code path either way, and speaking the protocol would add nothing +// to what is being measured. It never completes a handshake, so the transport +// is closed once the file has been written. +// +// The child reports only the keys a test names. Dumping the whole environment +// would write the runner's own secrets to a temp file to answer a question +// about a handful of variables. + +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "@effect/vitest"; + +import { createStdioTransport } from "./stdio-connector"; + +/** A variable only this test sets, standing in for a real one like + * `EXECUTOR_SECRET_KEY`. Using a fake keeps the test honest on a machine + * where the real one happens not to be set. */ +const HOST_ONLY_SECRET = "EXECUTOR_TEST_HOST_ONLY_SECRET"; +const HOST_ONLY_VALUE = "host-secret-that-must-not-reach-a-child"; + +const dirs: string[] = []; +/** Host variables a test set, restored rather than deleted: the machine + * running this may legitimately be behind a proxy. */ +const restore = new Map(); + +const setHostEnv = (key: string, value: string): void => { + if (!restore.has(key)) restore.set(key, process.env[key]); + process.env[key] = value; +}; + +afterEach(() => { + for (const [key, value] of restore) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + restore.clear(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** + * Spawn a child through the transport and return which of `probe` it saw. + * Keys the child did not receive are absent from the result. + */ +const envSeenByChild = async ( + declared: Record | undefined, + probe: ReadonlyArray, +): Promise> => { + const dir = mkdtempSync(join(tmpdir(), "executor-stdio-env-")); + dirs.push(dir); + const out = join(dir, "env.json"); + + const transport = createStdioTransport({ + command: process.execPath, + args: [ + "-e", + "const [out, ...keys] = process.argv.slice(1);" + + "require('node:fs').writeFileSync(out, JSON.stringify(Object.fromEntries(" + + "keys.flatMap((k) => (process.env[k] === undefined ? [] : [[k, process.env[k]]]))" + + ")))", + out, + ...probe, + ], + env: declared, + }); + + await transport.start(); + // The child writes and exits; poll briefly rather than assuming timing. + for (let i = 0; i < 100 && !existsSync(out); i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await transport.close(); + + // oxlint-disable-next-line executor/no-json-parse -- boundary: reading back the raw env dump this test's own child process just wrote; the value is only key-checked, never decoded into domain types + return JSON.parse(readFileSync(out, "utf8")) as Record; +}; + +describe("environment handed to a stdio MCP subprocess", () => { + it("does not leak a host secret to a server that declares its own env", async () => { + // The declared-env branch is the one that matters: it is the branch a + // credential-bearing integration takes, and it was the leaking one. + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); + + const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }, [ + HOST_ONLY_SECRET, + "DECLARED_TOKEN", + ]); + + expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); + // ...and the thing the integration actually asked for still arrives. + expect(childEnv.DECLARED_TOKEN).toBe("declared-value"); + }); + + it("does not leak a host secret to a server that declares no env", async () => { + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); + + const childEnv = await envSeenByChild(undefined, [HOST_ONLY_SECRET]); + + expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); + }); + + it("still provides the SDK's safe-list, so servers keep working", async () => { + // The fix must not strand servers that legitimately need PATH to find + // their own interpreter. The SDK's list is what supplies it. + const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }, ["PATH", "HOME"]); + + expect(childEnv.PATH).toBeDefined(); + expect(childEnv.HOME).toBeDefined(); + }); + + it("passes the host's proxy and CA configuration through, but nothing beside it", async () => { + // A server behind a corporate proxy or an intercepting CA cannot reach + // anything without these, and no source config declares them. Inheriting + // this short list is the whole difference between the fix landing and the + // fix breaking every install on such a network. + setHostEnv("HTTPS_PROXY", "http://proxy.invalid:3128"); + setHostEnv("no_proxy", "localhost,127.0.0.1"); + setHostEnv("NODE_EXTRA_CA_CERTS", "/etc/ssl/certs/corporate.pem"); + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); + + const childEnv = await envSeenByChild(undefined, [ + "HTTPS_PROXY", + "no_proxy", + "NODE_EXTRA_CA_CERTS", + HOST_ONLY_SECRET, + ]); + + expect(childEnv.HTTPS_PROXY).toBe("http://proxy.invalid:3128"); + expect(childEnv.no_proxy).toBe("localhost,127.0.0.1"); + expect(childEnv.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/corporate.pem"); + // The allowlist is an allowlist: a secret sitting beside those in the same + // environment still does not travel. + expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); + }); + + it("lets the source config override an inherited proxy value", async () => { + // The allowlist is merged underneath the declared env, so a source that + // needs its own egress route is not overruled by the host's. + setHostEnv("HTTPS_PROXY", "http://host-proxy.invalid:3128"); + + const childEnv = await envSeenByChild({ HTTPS_PROXY: "http://declared-proxy.invalid:8080" }, [ + "HTTPS_PROXY", + ]); + + expect(childEnv.HTTPS_PROXY).toBe("http://declared-proxy.invalid:8080"); + }); + + it("POSITIVE CONTROL: the child does report a variable when it is passed one", async () => { + // Proves the measurement works. Without this, a child that failed to + // write, or wrote an empty object, would satisfy every assertion above. + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); + + const childEnv = await envSeenByChild({ [HOST_ONLY_SECRET]: HOST_ONLY_VALUE }, [ + HOST_ONLY_SECRET, + ]); + + expect(childEnv[HOST_ONLY_SECRET]).toBe(HOST_ONLY_VALUE); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts b/packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts new file mode 100644 index 000000000..8bbd9a75e --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts @@ -0,0 +1,133 @@ +// How the inherited allowlist and the declared env combine, per platform. +// +// The sibling `stdio-env-isolation` suite spawns a real child and asks what it +// received. That answers the security question, but it can only ever measure +// the platform it runs on, and the defect here is Windows-only: a case-variant +// key collision. So this suite exercises the merge directly and passes the +// platform in, which lets the Windows behaviour be asserted from any host. + +import { describe, expect, it } from "@effect/vitest"; + +import { mergeStdioEnv } from "./stdio-connector"; + +/** The keys the SDK's own safe-list places underneath our result on Windows. */ +const windowsSdkKeys = ["APPDATA", "PATH", "SYSTEMROOT", "TEMP", "USERPROFILE"]; + +describe("mergeStdioEnv on a case-sensitive platform", () => { + it("keeps both spellings, because they are two real variables", () => { + // On Unix `HTTP_PROXY` and `http_proxy` are unrelated variables that + // different tooling reads, and the allowlist inherits both on purpose. + // Collapsing them here would drop configuration the host meant to pass. + const merged = mergeStdioEnv({ + platform: "linux", + inherited: { HTTP_PROXY: "http://host.invalid:3128" }, + declared: { http_proxy: "http://declared.invalid:8080" }, + }); + + expect(merged).toStrictEqual({ + HTTP_PROXY: "http://host.invalid:3128", + http_proxy: "http://declared.invalid:8080", + }); + }); + + it("lets the declared env win an exact key collision", () => { + const merged = mergeStdioEnv({ + platform: "darwin", + inherited: { HTTPS_PROXY: "http://host.invalid:3128" }, + declared: { HTTPS_PROXY: "http://declared.invalid:8080" }, + }); + + expect(merged).toStrictEqual({ HTTPS_PROXY: "http://declared.invalid:8080" }); + }); +}); + +describe("mergeStdioEnv on win32", () => { + it("drops the inherited alias so the declared value is the only one", () => { + // The defect: a case-sensitive spread emits `HTTP_PROXY` beside + // `http_proxy`, both reach the child's environment block, and Windows + // resolves the case-insensitive name to whichever comes first — not + // necessarily the declared one. One entry can only resolve one way. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: { HTTP_PROXY: "http://host.invalid:3128" }, + declared: { http_proxy: "http://declared.invalid:8080" }, + }); + + expect(Object.keys(merged)).toStrictEqual(["http_proxy"]); + expect(merged.http_proxy).toBe("http://declared.invalid:8080"); + expect(merged.HTTP_PROXY).toBeUndefined(); + }); + + it("collapses aliases the allowlist itself produces", () => { + // `process.env` is case-insensitive on Windows, so reading the allowlist's + // uppercase and lowercase proxy spellings returns the same variable twice. + // The child should be handed it once. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: { + HTTPS_PROXY: "http://host.invalid:3128", + https_proxy: "http://host.invalid:3128", + }, + }); + + expect(Object.keys(merged)).toStrictEqual(["https_proxy"]); + expect(merged.https_proxy).toBe("http://host.invalid:3128"); + }); + + it("emits a declared key with the SDK's spelling when the SDK also sets it", () => { + // The SDK spreads `getDefaultEnvironment()` underneath this result with a + // case-sensitive spread, and its Windows list spells the variable `PATH`. + // Returning `Path` would leave `PATH` standing beside it; returning `PATH` + // replaces it, which is what a source that overrides its interpreter + // lookup is asking for. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: {}, + declared: { Path: "C:\\server\\bin" }, + sdkKeys: windowsSdkKeys, + }); + + expect(Object.keys(merged)).toStrictEqual(["PATH"]); + expect(merged.PATH).toBe("C:\\server\\bin"); + }); + + it("leaves a key the SDK does not set under its declared spelling", () => { + const merged = mergeStdioEnv({ + platform: "win32", + inherited: {}, + declared: { My_Api_Token: "declared-value" }, + sdkKeys: windowsSdkKeys, + }); + + expect(merged).toStrictEqual({ My_Api_Token: "declared-value" }); + }); + + it("still passes an inherited variable the source does not declare", () => { + // Deduplication must not turn into dropping: the proxy and CA settings are + // the reason the allowlist exists. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: { NODE_EXTRA_CA_CERTS: "C:\\certs\\corporate.pem" }, + declared: { DECLARED_TOKEN: "declared-value" }, + sdkKeys: windowsSdkKeys, + }); + + expect(merged).toStrictEqual({ + NODE_EXTRA_CA_CERTS: "C:\\certs\\corporate.pem", + DECLARED_TOKEN: "declared-value", + }); + }); + + it("does not invent an entry for an SDK key nobody declared", () => { + // The SDK supplies its own safe-list; this merge only decides spelling for + // keys that are actually being passed. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: {}, + declared: {}, + sdkKeys: windowsSdkKeys, + }); + + expect(merged).toStrictEqual({}); + }); +});