fix(skill-mcp): treat opencode-project and local scopes as untrusted for env var access

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-08 17:18:17 +09:00
parent 06b825dd74
commit 001d29ea8e
3 changed files with 72 additions and 5 deletions
@@ -1,4 +1,4 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { afterAll, afterEach, beforeEach, describe, expect, it, mock, test } from "bun:test"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types" import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
@@ -89,12 +89,15 @@ function createState(): SkillMcpManagerState {
return state return state
} }
function createClientInfo(serverName: string): SkillMcpClientInfo { function createClientInfo(
serverName: string,
scope?: SkillMcpClientInfo["scope"],
): SkillMcpClientInfo {
return { return {
serverName, serverName,
skillName: "env-skill", skillName: "env-skill",
sessionID: "session-env", sessionID: "session-env",
scope: "builtin", ...(scope !== undefined ? { scope } : {}),
} }
} }
@@ -126,6 +129,68 @@ afterEach(async () => {
}) })
describe("getOrCreateClient env var expansion", () => { describe("getOrCreateClient env var expansion", () => {
describe("#given a scope-sensitive stdio skill MCP config", () => {
test.each([
["opencode-project", "Authorization:Bearer "],
["local", "Authorization:Bearer "],
["user", "Authorization:Bearer xoxp-scope-token"],
["builtin", "Authorization:Bearer xoxp-scope-token"],
] satisfies Array<[NonNullable<SkillMcpClientInfo["scope"]>, string]>) (
"#when creating the client for %s scope #then args expand to %s",
async (scope, expectedAuthorizationHeader) => {
// given
process.env.SLACK_USER_TOKEN = "xoxp-scope-token"
const state = createState()
const info = createClientInfo(`scope-${scope}`, scope)
const clientKey = createClientKey(info)
const config: ClaudeCodeMcpServer = {
command: "npx",
args: [
"-y",
"mcp-remote",
"https://mcp.slack.com/mcp",
"--header",
"Authorization:Bearer ${SLACK_USER_TOKEN}",
],
}
// when
await getOrCreateClient({ state, clientKey, info, config })
// then
expect(createdStdioTransports).toHaveLength(1)
expect(createdStdioTransports[0]?.options.args?.[4]).toBe(expectedAuthorizationHeader)
},
)
it("#when creating the client without scope #then env vars remain trusted for backward compatibility", async () => {
// given
process.env.SLACK_USER_TOKEN = "xoxp-undefined-scope-token"
const state = createState()
const info = createClientInfo("scope-undefined")
const clientKey = createClientKey(info)
const config: ClaudeCodeMcpServer = {
command: "npx",
args: [
"-y",
"mcp-remote",
"https://mcp.slack.com/mcp",
"--header",
"Authorization:Bearer ${SLACK_USER_TOKEN}",
],
}
// when
await getOrCreateClient({ state, clientKey, info, config })
// then
expect(createdStdioTransports).toHaveLength(1)
expect(createdStdioTransports[0]?.options.args?.[4]).toBe(
"Authorization:Bearer xoxp-undefined-scope-token",
)
})
})
describe("#given a stdio skill MCP config with sensitive env vars in args", () => { describe("#given a stdio skill MCP config with sensitive env vars in args", () => {
it("#when creating the client #then sensitive env vars in args are expanded", async () => { it("#when creating the client #then sensitive env vars in args are expanded", async () => {
// given // given
+3 -1
View File
@@ -14,6 +14,8 @@ function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, c
} }
} }
const PROJECT_SCOPES = new Set(["project", "opencode-project", "local"])
export async function getOrCreateClient(params: { export async function getOrCreateClient(params: {
state: SkillMcpManagerState state: SkillMcpManagerState
clientKey: string clientKey: string
@@ -38,7 +40,7 @@ export async function getOrCreateClient(params: {
return pending return pending
} }
const isTrusted = info.scope !== "project" const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "")
const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted }) const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted })
let currentConnectionPromise!: Promise<Client> let currentConnectionPromise!: Promise<Client>
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1) state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
+1 -1
View File
@@ -11,7 +11,7 @@ export interface SkillMcpClientInfo {
serverName: string serverName: string
skillName: string skillName: string
sessionID: string sessionID: string
scope?: SkillScope scope?: SkillScope | "local"
} }
export interface SkillMcpServerContext { export interface SkillMcpServerContext {