feat(cli): secure .env loading and enforce workspace trust in headless mode (#25814)

Co-authored-by: galz10 <galzahavi@google.com>
Co-authored-by: davidapierce <davidapierce@google.com>
This commit is contained in:
Emily Hedlund
2026-04-23 09:09:14 -07:00
committed by GitHub
parent a007f64d20
commit dba9b9a0ff
27 changed files with 881 additions and 489 deletions
+8
View File
@@ -20,6 +20,7 @@ import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
export const OAUTH_FILE = 'oauth_creds.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const AGENTS_DIR_NAME = '.agents';
@@ -86,6 +87,13 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME);
}
static getTrustedFoldersPath(): string {
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
}
return path.join(Storage.getGlobalGeminiDir(), TRUSTED_FOLDERS_FILENAME);
}
static getUserCommandsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'commands');
}
+3
View File
@@ -294,3 +294,6 @@ export type { Content, Part, FunctionCall } from '@google/genai';
// Export context types and profiles
export * from './context/types.js';
export * from './context/profiles.js';
// Export trust utility
export * from './utils/trust.js';
+6
View File
@@ -114,6 +114,12 @@ export class FatalToolExecutionError extends FatalError {
this.name = 'FatalToolExecutionError';
}
}
export class FatalUntrustedWorkspaceError extends FatalError {
constructor(message: string) {
super(message, 55);
this.name = 'FatalUntrustedWorkspaceError';
}
}
export class FatalCancellationError extends FatalError {
constructor(message: string) {
super(message, 130); // Standard exit code for SIGINT
+1
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
export const GEMINI_DIR = '.gemini';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
/**
* Returns the home directory.
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
TrustLevel,
loadTrustedFolders,
resetTrustedFoldersForTesting,
checkPathTrust,
} from './trust.js';
import { Storage } from '../config/storage.js';
import { lock } from 'proper-lockfile';
import { ideContextStore } from '../ide/ideContext.js';
import * as headless from './headless.js';
import { coreEvents } from './events.js';
vi.mock('proper-lockfile');
vi.mock('./headless.js', async (importOriginal) => {
const original = await importOriginal<typeof import('./headless.js')>();
return {
...original,
isHeadlessMode: vi.fn(),
};
});
describe('Trust Utility (Core)', () => {
const tempDir = path.join(
os.tmpdir(),
'gemini-trust-test-' + Math.random().toString(36).slice(2),
);
const trustedFoldersPath = path.join(tempDir, 'trustedFolders.json');
beforeEach(() => {
fs.mkdirSync(tempDir, { recursive: true });
vi.spyOn(Storage, 'getTrustedFoldersPath').mockReturnValue(
trustedFoldersPath,
);
vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined));
vi.mocked(headless.isHeadlessMode).mockReturnValue(false);
ideContextStore.clear();
resetTrustedFoldersForTesting();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should load empty config if file does not exist', () => {
const folders = loadTrustedFolders();
expect(folders.user.config).toEqual({});
expect(folders.errors).toEqual([]);
});
it('should load config from file', () => {
const config = {
[path.resolve('/trusted/path')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
// Use path.resolve for platform consistency in tests
const normalizedKey = path.resolve('/trusted/path').replace(/\\/g, '/');
const isWindows = process.platform === 'win32';
const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey;
expect(folders.user.config[finalKey]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should handle isPathTrusted with longest match', () => {
const config = {
[path.resolve('/a')]: TrustLevel.TRUST_FOLDER,
[path.resolve('/a/b')]: TrustLevel.DO_NOT_TRUST,
[path.resolve('/a/b/c')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
expect(folders.isPathTrusted(path.resolve('/a/file.txt'))).toBe(true);
expect(folders.isPathTrusted(path.resolve('/a/b/file.txt'))).toBe(false);
expect(folders.isPathTrusted(path.resolve('/a/b/c/file.txt'))).toBe(true);
expect(folders.isPathTrusted(path.resolve('/other'))).toBeUndefined();
});
it('should handle TRUST_PARENT', () => {
const config = {
[path.resolve('/project/.gemini')]: TrustLevel.TRUST_PARENT,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
expect(folders.isPathTrusted(path.resolve('/project/file.txt'))).toBe(true);
expect(
folders.isPathTrusted(path.resolve('/project/.gemini/config.yaml')),
).toBe(true);
});
it('should save config correctly', async () => {
const folders = loadTrustedFolders();
const testPath = path.resolve('/new/trusted/path');
await folders.setValue(testPath, TrustLevel.TRUST_FOLDER);
const savedContent = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
const normalizedKey = testPath.replace(/\\/g, '/');
const isWindows = process.platform === 'win32';
const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey;
expect(savedContent[finalKey]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should handle comments in JSON', () => {
const content = `
{
// This is a comment
"path": "TRUST_FOLDER"
}
`;
fs.writeFileSync(trustedFoldersPath, content);
const folders = loadTrustedFolders();
expect(folders.errors).toHaveLength(0);
});
describe('checkPathTrust', () => {
it('should NOT return trusted if headless mode is on by default', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
isHeadless: true,
});
expect(result).toEqual({ isTrusted: undefined, source: undefined });
});
it('should return trusted if folder trust is disabled', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: false,
});
expect(result).toEqual({ isTrusted: true, source: undefined });
});
it('should return IDE trust if available', () => {
ideContextStore.set({
workspaceState: { isTrusted: true },
});
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: true, source: 'ide' });
});
it('should fall back to file trust', () => {
const config = {
[path.resolve('/trusted')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const result = checkPathTrust({
path: path.resolve('/trusted/file.txt'),
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: true, source: 'file' });
});
it('should return undefined trust if no rule matches', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: undefined, source: undefined });
});
});
describe('coreEvents.emitFeedback', () => {
it('should report corrupted config via coreEvents.emitFeedback in setValue', async () => {
const folders = loadTrustedFolders();
const testPath = path.resolve('/new/path');
// Initialize with valid JSON
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
// Corrupt the file after initial load
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
const spy = vi.spyOn(coreEvents, 'emitFeedback');
await folders.setValue(testPath, TrustLevel.TRUST_FOLDER);
expect(spy).toHaveBeenCalledWith(
'error',
expect.stringContaining('may be corrupted'),
expect.any(Error),
);
});
});
});
+356
View File
@@ -0,0 +1,356 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import stripJsonComments from 'strip-json-comments';
import { Storage } from '../config/storage.js';
import { normalizePath, isSubpath } from './paths.js';
import { FatalConfigError, getErrorMessage } from './errors.js';
import { coreEvents } from './events.js';
import { ideContextStore } from '../ide/ideContext.js';
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | 'env' | undefined;
}
export interface TrustOptions {
path: string;
isFolderTrustEnabled: boolean;
isHeadless?: boolean;
}
export function isTrustLevel(value: unknown): value is TrustLevel {
return (
typeof value === 'string' &&
Object.values(TrustLevel).some((v) => v === value)
);
}
/**
* Checks if a path is trusted based on headless mode, folder trust settings,
* IDE context, and local configuration file.
*/
export function checkPathTrust(options: TrustOptions): TrustResult {
if (process.env['GEMINI_CLI_TRUST_WORKSPACE'] === 'true') {
return { isTrusted: true, source: 'env' };
}
if (!options.isFolderTrustEnabled) {
return { isTrusted: true, source: undefined };
}
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
if (ideTrust !== undefined) {
return { isTrusted: ideTrust, source: 'ide' };
}
const folders = loadTrustedFolders();
if (folders.errors.length > 0) {
const errorMessages = folders.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
);
}
const isTrusted = folders.isPathTrusted(options.path);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
};
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
readonly errors: TrustedFoldersError[],
) {}
get rules(): TrustRule[] {
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
path,
trustLevel,
}));
}
/**
* Returns true or false if the path should be "trusted" based on the configuration.
*
* @param location path
* @param config optional config override
* @returns boolean if trusted/distrusted, undefined if no rule matches
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
): boolean | undefined {
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
const normalizedLocation = normalizePath(realLocation);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
const normalizedEffectivePath = normalizePath(realEffectivePath);
if (isSubpath(normalizedEffectivePath, normalizedLocation)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
) {
return true;
}
return undefined;
}
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fs.promises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
// Restrict file access to read/write for the owner only
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
const normalizedPath = normalizePath(folderPath);
const originalTrustLevel = this.user.config[normalizedPath];
try {
// Re-read the file to handle concurrent updates
const content = await fs.promises.readFile(this.user.path, 'utf-8');
const config: Record<string, TrustLevel> = {};
try {
const parsed = parseTrustedFoldersJson(content);
if (isRecord(parsed)) {
for (const [rawPath, value] of Object.entries(parsed)) {
if (isTrustLevel(value)) {
config[rawPath] = value;
}
}
}
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
}
// Use normalized path as key
config[normalizedPath] = trustLevel;
this.user.config[normalizedPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[normalizedPath];
} else {
this.user.config[normalizedPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
/**
* FOR TESTING PURPOSES ONLY.
* Resets the in-memory cache of the trusted folders configuration.
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
if (loadedTrustedFolders) {
return loadedTrustedFolders;
}
const errors: TrustedFoldersError[] = [];
const userConfig: Record<string, TrustLevel> = {};
const userPath = Storage.getTrustedFoldersPath();
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = parseTrustedFoldersJson(content);
if (!isRecord(parsed)) {
errors.push({
message: 'Trusted folders file is not a valid JSON object.',
path: userPath,
});
} else {
for (const [rawPath, trustLevel] of Object.entries(parsed)) {
const normalizedPath = normalizePath(rawPath);
if (isTrustLevel(trustLevel)) {
userConfig[normalizedPath] = trustLevel;
} else {
const possibleValues = Object.values(TrustLevel).join(', ');
errors.push({
message: `Invalid trust level "${trustLevel}" for path "${rawPath}". Possible values are: ${possibleValues}.`,
path: userPath,
});
}
}
}
}
} catch (error) {
errors.push({
message: getErrorMessage(error),
path: userPath,
});
}
loadedTrustedFolders = new LoadedTrustedFolders(
{ path: userPath, config: userConfig },
errors,
);
return loadedTrustedFolders;
}
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
// Restrict file access to read/write for the owner only
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}