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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user