feat: add ignoreLocalEnv setting and --ignore-env flag (#2493) (#26445)

This commit is contained in:
Coco Sheng
2026-05-04 15:14:33 -04:00
committed by GitHub
parent 75a8de83fc
commit 493b555646
6 changed files with 276 additions and 3 deletions
+1
View File
@@ -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
+6
View File
@@ -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`
@@ -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<typeof osActual>();
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<typeof import('@google/gemini-cli-core')>();
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();
});
});
+14 -3
View File
@@ -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;
+10
View File
@@ -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',
+7
View File
@@ -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.",