fix(core): allow environment variable expansion and explicit overrides for MCP servers (#18837)

This commit is contained in:
Gal Zahavi
2026-02-23 13:35:01 -08:00
committed by GitHub
parent 31960c3388
commit 0bc2d3ab16
7 changed files with 334 additions and 12 deletions
+62 -2
View File
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
- **`args`** (string[]): Command-line arguments for Stdio transport - **`args`** (string[]): Command-line arguments for Stdio transport
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl` - **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
- **`env`** (object): Environment variables for the server process. Values can - **`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 - **`cwd`** (string): Working directory for Stdio transport
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = - **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
10 minutes) 10 minutes)
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
Service Account to impersonate. Used with Service Account to impersonate. Used with
`authProviderType: 'service_account_impersonation'`. `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 ### OAuth support for remote MCP servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using 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 - **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables - **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 - **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to - **Private data:** Using broadly scoped personal access tokens can lead to
+32 -3
View File
@@ -7432,9 +7432,36 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.1.0", "version": "17.2.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
"integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==", "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", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -17400,6 +17427,8 @@
"ajv-formats": "^3.0.0", "ajv-formats": "^3.0.0",
"chardet": "^2.1.0", "chardet": "^2.1.0",
"diff": "^8.0.3", "diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6", "fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6", "fdir": "^6.4.6",
"fzf": "^0.5.2", "fzf": "^0.5.2",
+2
View File
@@ -53,6 +53,8 @@
"ajv-formats": "^3.0.0", "ajv-formats": "^3.0.0",
"chardet": "^2.1.0", "chardet": "^2.1.0",
"diff": "^8.0.3", "diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6", "fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6", "fdir": "^6.4.6",
"fzf": "^0.5.2", "fzf": "^0.5.2",
@@ -1704,6 +1704,40 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined(); 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', () => { describe('useGoogleCredentialProvider', () => {
beforeEach(() => { beforeEach(() => {
// Mock GoogleAuth client // Mock GoogleAuth client
+33 -7
View File
@@ -70,6 +70,7 @@ import {
sanitizeEnvironment, sanitizeEnvironment,
type EnvironmentSanitizationConfig, type EnvironmentSanitizationConfig,
} from '../services/environmentSanitization.js'; } from '../services/environmentSanitization.js';
import { expandEnvVars } from '../utils/envExpansion.js';
import { import {
GEMINI_CLI_IDENTIFICATION_ENV_VAR, GEMINI_CLI_IDENTIFICATION_ENV_VAR,
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
@@ -783,9 +784,16 @@ function createTransportRequestInit(
mcpServerConfig: MCPServerConfig, mcpServerConfig: MCPServerConfig,
headers: Record<string, string>, headers: Record<string, string>,
): RequestInit { ): RequestInit {
const expandedHeaders: Record<string, string> = {};
if (mcpServerConfig.headers) {
for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
expandedHeaders[key] = expandEnvVars(value, process.env);
}
}
return { return {
headers: { headers: {
...mcpServerConfig.headers, ...expandedHeaders,
...headers, ...headers,
}, },
}; };
@@ -1970,15 +1978,33 @@ export async function createTransport(
} }
if (mcpServerConfig.command) { 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<string, string> = {
[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({ let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command, command: mcpServerConfig.command,
args: mcpServerConfig.args || [], args: mcpServerConfig.args || [],
env: { env: finalEnv,
...sanitizeEnvironment(process.env, sanitizationConfig),
...(mcpServerConfig.env || {}),
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
} as Record<string, string>,
cwd: mcpServerConfig.cwd, cwd: mcpServerConfig.cwd,
stderr: 'pipe', stderr: 'pipe',
}); });
@@ -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);
});
});
});
+54
View File
@@ -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, string | undefined>,
): 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<string, string> requirement safely
const processEnv: Record<string, string> = {};
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] ?? '';
}