fix(opencode): restrict local mcp environment

This commit is contained in:
Aiden Cline 2026-06-24 16:24:48 -05:00
commit d1adfdba16
3 changed files with 146 additions and 5 deletions

View file

@ -117,6 +117,53 @@ type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"]
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
const LOCAL_MCP_INHERITED_ENV = [
"APPDATA",
"COMSPEC",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"LANG",
"LANGUAGE",
"LC_ADDRESS",
"LC_ALL",
"LC_COLLATE",
"LC_CTYPE",
"LC_IDENTIFICATION",
"LC_MEASUREMENT",
"LC_MESSAGES",
"LC_MONETARY",
"LC_NAME",
"LC_NUMERIC",
"LC_PAPER",
"LC_TELEPHONE",
"LC_TIME",
"LOCALAPPDATA",
"LOGNAME",
"PATH",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"SHELL",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TERM",
"TMP",
"TMPDIR",
"USER",
"USERNAME",
"USERPROFILE",
"WINDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME",
] as const
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
return typeof entry === "object" && entry !== null && "type" in entry
}
@ -125,6 +172,21 @@ function remoteURL(value: string) {
if (URL.canParse(value)) return new URL(value)
}
function localMcpEnvironment(command: string, environment?: Record<string, string>) {
const inherited = Object.fromEntries(
LOCAL_MCP_INHERITED_ENV.flatMap((key) => {
const value = process.env[key]
if (value === undefined || value.startsWith("()")) return []
return [[key, value] as const]
}),
)
return {
...inherited,
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...environment,
}
}
interface CreateResult {
mcpClient?: MCPClient
status: Status
@ -338,11 +400,7 @@ export const layer = Layer.effect(
command: cmd,
args,
cwd,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
env: localMcpEnvironment(cmd, mcp.environment),
})
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT

View file

@ -0,0 +1,21 @@
import readline from "node:readline"
await Bun.write(process.env.MCP_ENV_OUTPUT!, JSON.stringify(process.env))
const lines = readline.createInterface({ input: process.stdin })
lines.on("close", () => process.exit(0))
lines.on("line", (line) => {
const request = JSON.parse(line) as { id?: number; method: string; params?: { protocolVersion?: string } }
if (request.method !== "initialize") return
process.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: {
protocolVersion: request.params?.protocolVersion,
capabilities: {},
serverInfo: { name: "environment-test", version: "1" },
},
})}\n`,
)
})

View file

@ -0,0 +1,62 @@
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { MCP } from "../../src/mcp/index"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(MCP.defaultLayer)
it.instance(
"local subprocess receives only baseline and configured environment",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const values = {
APPDATA: path.join(test.directory, "appdata"),
LC_TIME: "C",
OPENCODE_MCP_PARENT_SECRET: "parent-secret",
PATHEXT: ".EXE;.CMD",
SYSTEMROOT: path.join(test.directory, "windows"),
TMPDIR: test.directory,
}
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
Object.assign(process.env, values)
yield* MCP.Service.use((mcp) =>
Effect.gen(function* () {
const output = path.join(test.directory, "environment.json")
const result = yield* mcp.add("environment", {
type: "local",
command: [process.execPath, path.join(import.meta.dir, "../fixture/mcp-environment.ts")],
environment: {
MCP_ENV_OUTPUT: output,
MCP_EXPLICIT_TOKEN: "configured-token",
},
})
expect(result.status.environment).toEqual({ status: "connected" })
const env = (yield* Effect.promise(() => Bun.file(output).json())) as Record<string, string>
expect(env.OPENCODE_MCP_PARENT_SECRET).toBeUndefined()
expect(env.MCP_EXPLICIT_TOKEN).toBe("configured-token")
expect(env.PATH).toBe(process.env.PATH!)
expect(env.HOME).toBe(process.env.HOME!)
expect(env.TMPDIR).toBe(values.TMPDIR)
expect(env.LC_TIME).toBe(values.LC_TIME)
expect(env.APPDATA).toBe(values.APPDATA)
expect(env.PATHEXT).toBe(values.PATHEXT)
expect(env.SYSTEMROOT).toBe(values.SYSTEMROOT)
}).pipe(Effect.ensuring(mcp.disconnect("environment").pipe(Effect.ignore))),
).pipe(
Effect.ensuring(
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
),
)
}),
{ config: { mcp: {} } },
)