From 493b5556467b5e40c9ff0c78d268aab495ec9d14 Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Mon, 4 May 2026 15:14:33 -0400 Subject: [PATCH] feat: add ignoreLocalEnv setting and --ignore-env flag (#2493) (#26445) --- docs/cli/settings.md | 1 + docs/reference/configuration.md | 6 + .../src/config/settings-env-isolation.test.ts | 238 ++++++++++++++++++ packages/cli/src/config/settings.ts | 17 +- packages/cli/src/config/settingsSchema.ts | 10 + schemas/settings.schema.json | 7 + 6 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/config/settings-env-isolation.test.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index b908356ab6..c5e8a3d51b 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -158,6 +158,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` | +| Ignore Local .env | `advanced.ignoreLocalEnv` | Whether to ignore generic .env files in the project directory. | `false` | ### Experimental diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c75db12364..0897a69fa0 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1752,6 +1752,12 @@ their corresponding top-level category object in your `settings.json` file. ["DEBUG", "DEBUG_MODE"] ``` +- **`advanced.ignoreLocalEnv`** (boolean): + - **Description:** Whether to ignore generic .env files in the project + directory. + - **Default:** `false` + - **Requires restart:** Yes + - **`advanced.bugCommand`** (object): - **Description:** Configuration for the bug report command. - **Default:** `undefined` diff --git a/packages/cli/src/config/settings-env-isolation.test.ts b/packages/cli/src/config/settings-env-isolation.test.ts new file mode 100644 index 0000000000..526b85ef85 --- /dev/null +++ b/packages/cli/src/config/settings-env-isolation.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import type * as osActual from 'node:os'; + +vi.mock('node:os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => path.resolve('/mock/home')), + platform: vi.fn(() => 'linux'), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: vi.fn(() => path.resolve('/mock/home')), + }; +}); + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { loadEnvironment, type Settings } from './settings.js'; +import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core'; + +vi.mock('node:fs'); + +describe('Environment Isolation', () => { + const mockHome = path.resolve('/mock/home'); + const mockWorkspace = path.resolve('/mock/workspace'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue(mockHome); + vi.mocked(coreHomedir).mockReturnValue(mockHome); + // Default to no files existing + vi.mocked(fs.existsSync).mockReturnValue(false); + process.argv = ['node', 'gemini']; + + // Clear env vars that might leak from the host environment + delete process.env['GEMINI_API_KEY']; + delete process.env['OTHER_VAR']; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = { ...originalEnv }; + }); + + it('should load local .env by default', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('local'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should ignore local .env when ignoreLocalEnv is true', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + const homeEnv = path.join(mockHome, '.env'); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const ps = p.toString(); + return ps === workspaceEnv || ps === homeEnv; + }); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const ps = p.toString(); + if (ps === workspaceEnv) return 'GEMINI_API_KEY=local'; + if (ps === homeEnv) return 'GEMINI_API_KEY=home'; + return ''; + }); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + // Should skip local and find home + expect(process.env['GEMINI_API_KEY']).toBe('home'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should still load .gemini/.env even if ignoreLocalEnv is true', () => { + const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceGeminiEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local'); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('gemini-local'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should respect --ignore-env flag', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + process.argv = ['node', 'gemini', '--ignore-env']; + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBeUndefined(); + }); + + it('should allow home .env even with ignoreLocalEnv true', () => { + const homeEnv = path.join(mockHome, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === homeEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home'); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + // Running from home dir + loadEnvironment(settings, mockHome, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('home'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => { + const deepProject = path.join(mockWorkspace, 'deep', 'dir'); + const deepEnv = path.join(deepProject, '.env'); + const parentEnv = path.join(mockWorkspace, '.env'); + const homeEnv = path.join(mockHome, '.env'); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const ps = p.toString(); + return ps === deepEnv || ps === parentEnv || ps === homeEnv; + }); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const ps = p.toString(); + if (ps === deepEnv) return 'GEMINI_API_KEY=deep'; + if (ps === parentEnv) return 'GEMINI_API_KEY=parent'; + if (ps === homeEnv) return 'GEMINI_API_KEY=home'; + return ''; + }); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + loadEnvironment(settings, deepProject, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('home'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should respect trust whitelist even when loading from home .env', () => { + const homeEnv = path.join(mockHome, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === homeEnv, + ); + // Include one whitelisted and one non-whitelisted variable + vi.mocked(fs.readFileSync).mockReturnValue( + 'GEMINI_API_KEY=home\nOTHER_VAR=secret', + ); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + // Running from an UNTRUSTED workspace + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: false, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('home'); + expect(process.env['OTHER_VAR']).toBeUndefined(); + delete process.env['GEMINI_API_KEY']; + }); + + it('should prioritize --ignore-env flag even if setting is false', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + process.argv = ['node', 'gemini', '--ignore-env']; + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBeUndefined(); + }); + + it('should respect both -s and --ignore-env flags simultaneously', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + process.argv = ['node', 'gemini', '-s', '--ignore-env']; + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 5a52e5af3c..cd6b3c61cb 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -500,7 +500,11 @@ export class LoadedSettings { } } -function findEnvFile(startDir: string, isTrusted: boolean): string | null { +function findEnvFile( + startDir: string, + isTrusted: boolean, + ignoreLocalEnv: boolean, +): string | null { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under GEMINI_DIR @@ -512,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null { } const envPath = path.join(currentDir, '.env'); if (fs.existsSync(envPath)) { - return envPath; + if (!ignoreLocalEnv || currentDir === homedir()) { + return envPath; + } } const parentDir = path.dirname(currentDir); if (parentDir === currentDir || !parentDir) { @@ -595,7 +601,6 @@ export function loadEnvironment( ): void { const trustResult = isWorkspaceTrustedFn(settings, workspaceDir); const isTrusted = trustResult.isTrusted ?? false; - const envFilePath = findEnvFile(workspaceDir, isTrusted); // Check settings OR check process.argv directly since this might be called // before arguments are fully parsed. This is a best-effort sniffing approach @@ -612,6 +617,12 @@ export function loadEnvironment( relevantArgs.includes('-s') || relevantArgs.includes('--sandbox'); + const shouldIgnoreEnv = + !!settings.advanced?.ignoreLocalEnv || + relevantArgs.includes('--ignore-env'); + + const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv); + // Cloud Shell environment variable handling if (process.env['CLOUD_SHELL'] === 'true') { const selectedAuthType = settings.security?.auth?.selectedType; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 54a016b0b0..d27457bcd6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = { items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, + ignoreLocalEnv: { + type: 'boolean', + label: 'Ignore Local .env', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Whether to ignore generic .env files in the project directory.', + showInDialog: true, + }, bugCommand: { type: 'object', label: 'Bug Command', diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index c4d33a7414..6e307f6966 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -3031,6 +3031,13 @@ "type": "string" } }, + "ignoreLocalEnv": { + "title": "Ignore Local .env", + "description": "Whether to ignore generic .env files in the project directory.", + "markdownDescription": "Whether to ignore generic .env files in the project directory.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "bugCommand": { "title": "Bug Command", "description": "Configuration for the bug report command.",