fix(mcp): bypass env var allowlist for trusted skill MCP configs (#3168)

Added 'trusted' option to expandEnvVars. Skill MCPs are user-controlled
and now bypass the security allowlist. 3 files changed.

TDD verified. tsc clean.

Closes #3168
This commit is contained in:
YeonGyu-Kim
2026-04-07 15:11:47 +09:00
parent 3e8fd5ff18
commit 73d407fe73
4 changed files with 312 additions and 7 deletions
@@ -120,6 +120,32 @@ describe("expandEnvVars", () => {
expect(expanded).toBe("user-approved")
})
})
describe("#given a sensitive environment variable expanded in trusted mode", () => {
it("#when expanding the value #then it returns the env value bypassing the allowlist", () => {
// given
process.env.SLACK_USER_TOKEN = "xoxp-trusted"
// when
const expanded = expandEnvVars("${SLACK_USER_TOKEN}", { trusted: true })
// then
expect(expanded).toBe("xoxp-trusted")
})
})
describe("#given an unset env var expanded in trusted mode with a default", () => {
it("#when expanding the value #then it returns the default value", () => {
// given
delete process.env.UNSET_TRUSTED_VAR
// when
const expanded = expandEnvVars("${UNSET_TRUSTED_VAR:-fallback}", { trusted: true })
// then
expect(expanded).toBe("fallback")
})
})
})
describe("expandEnvVarsInObject", () => {
@@ -165,4 +191,69 @@ describe("expandEnvVarsInObject", () => {
})
})
})
describe("#given a trusted skill MCP config object with sensitive env vars", () => {
it("#when expanding env vars in trusted mode #then it expands all referenced env vars", () => {
// given
process.env.SLACK_USER_TOKEN = "xoxp-trusted-token"
process.env.HOME = "/Users/tester"
// when
const expanded = expandEnvVarsInObject(
{
command: "npx",
args: [
"-y",
"mcp-remote",
"https://mcp.slack.com/mcp",
"--header",
"Authorization:Bearer ${SLACK_USER_TOKEN}",
],
env: {
HOME_DIR: "${HOME}",
},
},
{ trusted: true }
)
// then
expect(expanded).toEqual({
command: "npx",
args: [
"-y",
"mcp-remote",
"https://mcp.slack.com/mcp",
"--header",
"Authorization:Bearer xoxp-trusted-token",
],
env: {
HOME_DIR: "/Users/tester",
},
})
})
it("#when expanding a remote http skill MCP config in trusted mode #then it expands sensitive headers", () => {
// given
process.env.SLACK_USER_TOKEN = "xoxp-trusted-token"
// when
const expanded = expandEnvVarsInObject(
{
url: "https://mcp.slack.com/mcp",
headers: {
Authorization: "Bearer ${SLACK_USER_TOKEN}",
},
},
{ trusted: true }
)
// then
expect(expanded).toEqual({
url: "https://mcp.slack.com/mcp",
headers: {
Authorization: "Bearer xoxp-trusted-token",
},
})
})
})
})
@@ -4,11 +4,16 @@ import {
isSensitiveMcpEnvVar,
} from "./configure-allowed-env-vars"
export function expandEnvVars(value: string): string {
export interface ExpandEnvVarsOptions {
trusted?: boolean
}
export function expandEnvVars(value: string, options: ExpandEnvVarsOptions = {}): string {
const { trusted = false } = options
return value.replace(
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
(_, varName: string, defaultValue?: string) => {
if (!isAllowedMcpEnvVar(varName)) {
if (!trusted && !isAllowedMcpEnvVar(varName)) {
const isSensitive = isSensitiveMcpEnvVar(varName)
const reason = isSensitive ? "sensitive variable" : "not in allowlist"
@@ -29,16 +34,16 @@ export function expandEnvVars(value: string): string {
)
}
export function expandEnvVarsInObject<T>(obj: T): T {
export function expandEnvVarsInObject<T>(obj: T, options: ExpandEnvVarsOptions = {}): T {
if (obj === null || obj === undefined) return obj
if (typeof obj === "string") return expandEnvVars(obj) as T
if (typeof obj === "string") return expandEnvVars(obj, options) as T
if (Array.isArray(obj)) {
return obj.map((item) => expandEnvVarsInObject(item)) as T
return obj.map((item) => expandEnvVarsInObject(item, options)) as T
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = expandEnvVarsInObject(value)
result[key] = expandEnvVarsInObject(value, options)
}
return result as T
}