diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 09726432fd..22ce748918 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -163,7 +163,8 @@ Each server configuration supports the following properties: - **`args`** (string[]): Command-line arguments for Stdio transport - **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl` - **`env`** (object): Environment variables for the server process. Values can - reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax + reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all + platforms), or `%VAR_NAME%` (Windows only). - **`cwd`** (string): Working directory for Stdio transport - **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = 10 minutes) @@ -184,6 +185,63 @@ Each server configuration supports the following properties: Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`. +### Environment variable expansion + +Gemini CLI automatically expands environment variables in the `env` block of +your MCP server configuration. This allows you to securely reference variables +defined in your shell or environment without hardcoding sensitive information +directly in your `settings.json` file. + +The expansion utility supports: + +- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on + all platforms) +- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows) + +If a variable is not defined in the current environment, it resolves to an empty +string. + +**Example:** + +```json +"env": { + "API_KEY": "$MY_EXTERNAL_TOKEN", + "LOG_LEVEL": "$LOG_LEVEL", + "TEMP_DIR": "%TEMP%" +} +``` + +### Security and environment sanitization + +To protect your credentials, Gemini CLI performs environment sanitization when +spawning MCP server processes. + +#### Automatic redaction + +By default, the CLI redacts sensitive environment variables from the base +environment (inherited from the host process) to prevent unintended exposure to +third-party MCP servers. This includes: + +- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc. +- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, + `*KEY*`, `*AUTH*`, `*CREDENTIAL*`. +- Certificates and private key patterns. + +#### Explicit overrides + +If an environment variable must be passed to an MCP server, you must explicitly +state it in the `env` property of the server configuration in `settings.json`. +Explicitly defined variables (including those from extensions) are trusted and +are **not** subjected to the automatic redaction process. + +This follows the security principle that if a variable is explicitly configured +by the user for a specific server, it constitutes informed consent to share that +specific data with that server. + +> **Note:** Even when explicitly defined, you should avoid hardcoding secrets. +> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to +> securely pull the value from your host environment at runtime. + ### OAuth support for remote MCP servers The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using @@ -738,7 +796,9 @@ The MCP integration tracks several states: - **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use cautiously and only for servers you completely control - **Access tokens:** Be security-aware when configuring environment variables - containing API keys or tokens + containing API keys or tokens. See + [Security and environment sanitization](#security-and-environment-sanitization) + for details on how Gemini CLI protects your credentials. - **Sandbox compatibility:** When using sandboxing, ensure MCP servers are available within the sandbox environment - **Private data:** Using broadly scoped personal access tokens can lead to diff --git a/package-lock.json b/package-lock.json index 0bfce7daa0..2b027e0246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7432,9 +7432,36 @@ } }, "node_modules/dotenv": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", - "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", + "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -17400,6 +17427,8 @@ "ajv-formats": "^3.0.0", "chardet": "^2.1.0", "diff": "^8.0.3", + "dotenv": "^17.2.4", + "dotenv-expand": "^12.0.3", "fast-levenshtein": "^2.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", diff --git a/packages/core/package.json b/packages/core/package.json index e01efe9b3f..9995dabe18 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,6 +53,8 @@ "ajv-formats": "^3.0.0", "chardet": "^2.1.0", "diff": "^8.0.3", + "dotenv": "^17.2.4", + "dotenv-expand": "^12.0.3", "fast-levenshtein": "^2.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 3e592825dd..39d6c0c04b 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -1704,6 +1704,40 @@ describe('mcp-client', () => { expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined(); }); + it('should expand environment variables in mcpServerConfig.env and not redact them', async () => { + const mockedTransport = vi + .spyOn(SdkClientStdioLib, 'StdioClientTransport') + .mockReturnValue({} as SdkClientStdioLib.StdioClientTransport); + + const originalEnv = process.env; + process.env = { + ...originalEnv, + GEMINI_TEST_VAR: 'expanded-value', + }; + + try { + await createTransport( + 'test-server', + { + command: 'test-command', + env: { + TEST_EXPANDED: 'Value is $GEMINI_TEST_VAR', + SECRET_KEY: 'intentional-secret-123', + }, + }, + false, + EMPTY_CONFIG, + ); + + const callArgs = mockedTransport.mock.calls[0][0]; + expect(callArgs.env).toBeDefined(); + expect(callArgs.env!['TEST_EXPANDED']).toBe('Value is expanded-value'); + expect(callArgs.env!['SECRET_KEY']).toBe('intentional-secret-123'); + } finally { + process.env = originalEnv; + } + }); + describe('useGoogleCredentialProvider', () => { beforeEach(() => { // Mock GoogleAuth client diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 5e802e8157..58b211f46e 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -70,6 +70,7 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from '../services/environmentSanitization.js'; +import { expandEnvVars } from '../utils/envExpansion.js'; import { GEMINI_CLI_IDENTIFICATION_ENV_VAR, GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, @@ -783,9 +784,16 @@ function createTransportRequestInit( mcpServerConfig: MCPServerConfig, headers: Record, ): RequestInit { + const expandedHeaders: Record = {}; + if (mcpServerConfig.headers) { + for (const [key, value] of Object.entries(mcpServerConfig.headers)) { + expandedHeaders[key] = expandEnvVars(value, process.env); + } + } + return { headers: { - ...mcpServerConfig.headers, + ...expandedHeaders, ...headers, }, }; @@ -1970,15 +1978,33 @@ export async function createTransport( } if (mcpServerConfig.command) { + // 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets. + const sanitizedEnv = sanitizeEnvironment(process.env, { + ...sanitizationConfig, + enableEnvironmentVariableRedaction: true, + }); + + const finalEnv: Record = { + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + }; + for (const [key, value] of Object.entries(sanitizedEnv)) { + if (value !== undefined) { + finalEnv[key] = value; + } + } + + // Expand and merge explicit environment variables from the MCP configuration. + if (mcpServerConfig.env) { + for (const [key, value] of Object.entries(mcpServerConfig.env)) { + finalEnv[key] = expandEnvVars(value, process.env); + } + } + let transport: Transport = new StdioClientTransport({ command: mcpServerConfig.command, args: mcpServerConfig.args || [], - env: { - ...sanitizeEnvironment(process.env, sanitizationConfig), - ...(mcpServerConfig.env || {}), - [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: - GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, - } as Record, + env: finalEnv, cwd: mcpServerConfig.cwd, stderr: 'pipe', }); diff --git a/packages/core/src/utils/envExpansion.test.ts b/packages/core/src/utils/envExpansion.test.ts new file mode 100644 index 0000000000..e130a5d9de --- /dev/null +++ b/packages/core/src/utils/envExpansion.test.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { expandEnvVars } from './envExpansion.js'; + +describe('expandEnvVars', () => { + const defaultEnv = { + USER: 'morty', + HOME: '/home/morty', + TEMP: 'C:\\Temp', + EMPTY: '', + }; + + describe('POSIX behavior (non-Windows)', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + ['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'], + [ + '${VAR} (POSIX)', + 'Welcome to ${HOME}', + defaultEnv, + 'Welcome to /home/morty', + ], + [ + 'should NOT expand %VAR% on non-Windows', + 'Data in %TEMP%', + defaultEnv, + 'Data in %TEMP%', + ], + [ + 'mixed formats (only POSIX expanded)', + '$USER lives in ${HOME} on %TEMP%', + defaultEnv, + 'morty lives in /home/morty on %TEMP%', + ], + [ + 'missing variables (POSIX only)', + 'Missing $UNDEFINED and ${NONE} and %MISSING%', + defaultEnv, + 'Missing and and %MISSING%', + ], + [ + 'empty or undefined values', + 'Value is "$EMPTY"', + defaultEnv, + 'Value is ""', + ], + [ + 'original string if no variables', + 'No vars here', + defaultEnv, + 'No vars here', + ], + ['literal values like "1234"', '1234', defaultEnv, '1234'], + ['empty input string', '', defaultEnv, ''], + [ + 'complex paths', + '${HOME}/bin:$PATH', + { ...defaultEnv, PATH: '/usr/bin' }, + '/home/morty/bin:/usr/bin', + ], + ])('should handle %s', (_, input, env, expected) => { + expect(expandEnvVars(input, env)).toBe(expected); + }); + }); + + describe('Windows behavior', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + ['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'], + [ + '${VAR} (POSIX)', + 'Welcome to ${HOME}', + defaultEnv, + 'Welcome to /home/morty', + ], + [ + 'should expand %VAR% on Windows', + 'Data in %TEMP%', + defaultEnv, + 'Data in C:\\Temp', + ], + [ + 'mixed formats (both expanded)', + '$USER lives in ${HOME} on %TEMP%', + defaultEnv, + 'morty lives in /home/morty on C:\\Temp', + ], + [ + 'missing variables (all expanded to empty)', + 'Missing $UNDEFINED and ${NONE} and %MISSING%', + defaultEnv, + 'Missing and and ', + ], + ])('should handle %s', (_, input, env, expected) => { + expect(expandEnvVars(input, env)).toBe(expected); + }); + }); +}); diff --git a/packages/core/src/utils/envExpansion.ts b/packages/core/src/utils/envExpansion.ts new file mode 100644 index 0000000000..938d439ac5 --- /dev/null +++ b/packages/core/src/utils/envExpansion.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expand } from 'dotenv-expand'; + +/** + * Expands environment variables in a string using the provided environment record. + * Uses the standard `dotenv-expand` library to handle expansion consistently with + * other tools. + * + * Supports POSIX/Bash syntax ($VAR, ${VAR}). + * Note: Windows syntax (%VAR%) is not natively supported by dotenv-expand. + * + * @param str - The string containing environment variable placeholders. + * @param env - A record of environment variable names and their values. + * @returns The string with environment variables expanded. Missing variables resolve to an empty string. + */ +export function expandEnvVars( + str: string, + env: Record, +): string { + if (!str) return str; + + // 1. Pre-process Windows-style variables (%VAR%) since dotenv-expand only handles POSIX ($VAR). + // We only do this on Windows to limit the blast radius and avoid conflicts with other + // systems where % might be a literal character (e.g. in URLs or shell commands). + const isWindows = process.platform === 'win32'; + const processedStr = isWindows + ? str.replace(/%(\w+)%/g, (_, name) => env[name] ?? '') + : str; + + // 2. Use dotenv-expand for POSIX/Bash syntax ($VAR, ${VAR}). + // dotenv-expand is designed to process an object of key-value pairs (like a .env file). + // To expand a single string, we wrap it in an object with a temporary key. + const dummyKey = '__GCLI_EXPAND_TARGET__'; + + // Filter out undefined values to satisfy the Record requirement safely + const processEnv: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + processEnv[key] = value; + } + } + + const result = expand({ + parsed: { [dummyKey]: processedStr }, + processEnv, + }); + + return result.parsed?.[dummyKey] ?? ''; +}