mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
fix(core): allow environment variable expansion and explicit overrides for MCP servers (#18837)
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+32
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>,
|
||||
): 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 {
|
||||
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<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({
|
||||
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<string, string>,
|
||||
env: finalEnv,
|
||||
cwd: mcpServerConfig.cwd,
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
Reference in New Issue
Block a user