167 lines · 6.8 KB · TypeScript
RawDownload
// 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<string, string | undefined>(); 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<string, string> | undefined,  probe: ReadonlyArray<string>,): Promise<Record<string, string>> => {  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<string, string>;}; 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);  });});