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 Sandy Tao
parent 398f78dcaa
commit 77ab9e6ac0
27 changed files with 1341 additions and 773 deletions
+1
View File
@@ -28,6 +28,7 @@ runs:
- name: 'Run Tests'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
working-directory: '${{ inputs.working-directory }}'
run: |-
echo "::group::Build"
@@ -98,6 +98,7 @@ runs:
working-directory: '${{ inputs.working-directory }}'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
# We must diable CI mode here because it interferes with interactive tests.
# See https://github.com/google-gemini/gemini-cli/issues/10517
+3
View File
@@ -167,6 +167,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
VERBOSE: 'true'
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
@@ -212,6 +213,7 @@ jobs:
if: "${{runner.os != 'Windows'}}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
@@ -288,6 +290,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
+3
View File
@@ -173,6 +173,7 @@ jobs:
- name: 'Run tests and generate reports'
env:
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli"
@@ -261,6 +262,7 @@ jobs:
- name: 'Run tests and generate reports'
env:
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
@@ -424,6 +426,7 @@ jobs:
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test'
+3
View File
@@ -62,6 +62,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}'
@@ -105,6 +106,7 @@ jobs:
if: "runner.os != 'Windows'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}'
SANDBOX: 'sandbox:none'
@@ -159,6 +161,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
+1
View File
@@ -141,6 +141,7 @@ jobs:
if: "github.event_name != 'pull_request'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
+1
View File
@@ -52,6 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
+24
View File
@@ -100,6 +100,30 @@ protect you. In this mode, the following features are disabled:
Granting trust to a folder unlocks the full functionality of Gemini CLI for that
workspace.
## Headless and automated environments
When running Gemini CLI in a headless environment (for example, a CI/CD
pipeline) where interactive prompts are not possible, the trust dialog cannot be
displayed. If the folder is untrusted and the Folder Trust feature is enabled,
the CLI will throw a `FatalUntrustedWorkspaceError` and exit.
To proceed in these environments, you can bypass the trust check using one of
the following methods:
- **Command-line flag:** Run the CLI with the `--skip-trust` flag.
- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true`
environment variable.
These methods will trust the current workspace for the duration of the session
without prompting.
## Overriding the trust file location
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
need to store this file in a different location, you can set the
`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute
file path.
## Managing your trust settings
If you need to change a decision or see all your settings, you have a couple of
+8
View File
@@ -2099,6 +2099,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
`$env:GEMINI_MODEL="gemini-3-flash-preview"`)
- **`GEMINI_CLI_TRUST_WORKSPACE`**:
- If set to `"true"`, trusts the current workspace for the duration of the
session, bypassing the folder trust check.
- Useful for headless environments (for example, CI/CD pipelines).
- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**:
- Overrides the default location for the `trustedFolders.json` file.
- Useful if you want to store this configuration in a custom location instead
of the default `~/.gemini/`.
- **`GEMINI_CLI_IDE_PID`**:
- Manually specifies the PID of the IDE process to use for integration. This
is useful when running Gemini CLI in a standalone terminal while still
+492 -337
View File
File diff suppressed because it is too large Load Diff
+29 -12
View File
@@ -23,6 +23,7 @@ import {
FileDiscoveryService,
resolveTelemetrySettings,
FatalConfigError,
getErrorMessage,
getPty,
debugLogger,
loadServerHierarchicalMemory,
@@ -59,6 +60,7 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { isRecord } from '../utils/settingsUtils.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -105,6 +107,7 @@ export interface CliArgs {
startupMessages?: string[];
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
skipTrust: boolean | undefined;
isCommand: boolean | undefined;
}
@@ -288,6 +291,11 @@ export async function parseArguments(
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('skip-trust', {
type: 'boolean',
description: 'Trust the current workspace for this session.',
default: false,
})
.option('worktree', {
alias: 'w',
type: 'string',
@@ -456,9 +464,16 @@ export async function parseArguments(
yargsInstance.wrap(yargsInstance.terminalWidth());
let result;
try {
result = await yargsInstance.parse();
const parsed = await yargsInstance.parse();
if (!isRecord(parsed)) {
throw new Error('Failed to parse arguments');
}
result = parsed;
if (result['skip-trust']) {
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const msg = getErrorMessage(e);
debugLogger.error(msg);
yargsInstance.showHelp();
await runExitCleanup();
@@ -472,11 +487,13 @@ export async function parseArguments(
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
? queryArg.join(' ')
: queryArg;
const queryArg = result['query'];
let q: string | undefined;
if (Array.isArray(queryArg)) {
q = queryArg.join(' ');
} else if (typeof queryArg === 'string') {
q = queryArg;
}
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) {
@@ -491,8 +508,8 @@ export async function parseArguments(
}
// Keep CliArgs.query as a string for downstream typing
(result as Record<string, unknown>)['query'] = q || undefined;
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
result['query'] = q || undefined;
result['startupMessages'] = startupMessages;
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
@@ -544,7 +561,7 @@ export async function loadCliConfig(
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder =
isWorkspaceTrusted(settings, cwd, undefined, {
isWorkspaceTrusted(settings, cwd, {
prompt: argv.prompt,
query: argv.query,
})?.isTrusted ?? false;
@@ -590,7 +607,7 @@ export async function loadCliConfig(
return resolveToRealPath(trimmedPath) !== realCwd;
} catch (e) {
debugLogger.debug(
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
);
return false;
}
@@ -1092,7 +1109,7 @@ async function resolveWorktreeSettings(
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
);
}
+5 -1
View File
@@ -26,6 +26,7 @@ import {
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
normalizePath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -1420,6 +1421,7 @@ name = "yolo-checker"
'.gemini',
'trustedFolders.json',
);
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
@@ -1438,7 +1440,9 @@ name = "yolo-checker"
const trustedFolders = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
'TRUST_FOLDER',
);
});
describe.each([true, false])(
+12 -6
View File
@@ -1912,6 +1912,9 @@ describe('Settings Loading and Merging', () => {
const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
);
const workspaceEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, '.env'),
);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: isWorkspaceTrustedValue,
@@ -1919,9 +1922,11 @@ describe('Settings Loading and Merging', () => {
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normalizedP = path.resolve(p.toString());
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
normalizedP,
);
return [
path.resolve(USER_SETTINGS_PATH),
geminiEnvPath,
workspaceEnvPath,
].includes(normalizedP);
});
const userSettingsContent: Settings = {
ui: {
@@ -1941,7 +1946,7 @@ describe('Settings Loading and Merging', () => {
const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath)
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
return '{}';
},
@@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => {
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does load env files from untrusted spaces when NOT sandboxed', () => {
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
@@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => {
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files when trust is undefined and sandboxed', () => {
+15 -11
View File
@@ -494,13 +494,15 @@ export class LoadedSettings {
}
}
function findEnvFile(startDir: string): string | null {
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
if (isTrusted) {
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
@@ -509,9 +511,11 @@ function findEnvFile(startDir: string): string | null {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
if (isTrusted) {
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
@@ -554,10 +558,10 @@ export function loadEnvironment(
workspaceDir: string,
isWorkspaceTrustedFn = isWorkspaceTrusted,
): void {
const envFilePath = findEnvFile(workspaceDir);
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
// that happens early in the CLI lifecycle. It is designed to detect the
@@ -592,8 +596,8 @@ export function loadEnvironment(
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
if (!isTrusted && isSandboxed) {
// If the workspace is untrusted, only allow whitelisted variables.
if (!isTrusted) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue;
}
+49 -41
View File
@@ -11,7 +11,7 @@ import * as os from 'node:os';
import {
FatalConfigError,
ideContextStore,
coreEvents,
normalizePath,
} from '@google/gemini-cli-core';
import {
loadTrustedFolders,
@@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
homedir: () => '/mock/home/user',
isHeadlessMode: vi.fn(() => false),
coreEvents: {
emitFeedback: vi.fn(),
},
coreEvents: Object.assign(
Object.create(Object.getPrototypeOf(actual.coreEvents)),
actual.coreEvents,
{
emitFeedback: vi.fn(),
},
),
FatalConfigError: actual.FatalConfigError,
};
});
@@ -53,6 +58,7 @@ describe('Trusted Folders', () => {
// Reset the internal state
resetTrustedFoldersForTesting();
vi.clearAllMocks();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
});
afterEach(() => {
@@ -70,8 +76,14 @@ describe('Trusted Folders', () => {
// Start two concurrent calls
// These will race to acquire the lock on the real file system
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
const p1 = loadedFolders.setValue(
path.resolve('/path1'),
TrustLevel.TRUST_FOLDER,
);
const p2 = loadedFolders.setValue(
path.resolve('/path2'),
TrustLevel.TRUST_FOLDER,
);
await Promise.all([p1, p2]);
@@ -80,8 +92,8 @@ describe('Trusted Folders', () => {
const config = JSON.parse(content);
expect(config).toEqual({
'/path1': TrustLevel.TRUST_FOLDER,
'/path2': TrustLevel.TRUST_FOLDER,
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
});
});
});
@@ -95,13 +107,16 @@ describe('Trusted Folders', () => {
it('should load rules from the configuration file', () => {
const config = {
'/user/folder': TrustLevel.TRUST_FOLDER,
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
{
path: normalizePath('/user/folder'),
trustLevel: TrustLevel.TRUST_FOLDER,
},
]);
expect(errors).toEqual([]);
});
@@ -143,14 +158,14 @@ describe('Trusted Folders', () => {
const content = `
{
// This is a comment
"/path": "TRUST_FOLDER"
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
}
`;
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
]);
expect(errors).toEqual([]);
});
@@ -216,15 +231,18 @@ describe('Trusted Folders', () => {
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders();
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
await loadedFolders.setValue(
normalizePath('/new/path'),
TrustLevel.TRUST_FOLDER,
);
expect(loadedFolders.user.config['/new/path']).toBe(
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
TrustLevel.TRUST_FOLDER,
);
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content);
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should throw FatalConfigError if there were load errors', async () => {
@@ -237,28 +255,6 @@ describe('Trusted Folders', () => {
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
).rejects.toThrow(FatalConfigError);
});
it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => {
// Initialize with valid JSON
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders();
// Corrupt the file after initial load
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
expect.stringContaining('may be corrupted'),
expect.any(Error),
);
// Should have overwritten the corrupted file with new valid config
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content);
expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER });
});
});
describe('isWorkspaceTrusted Integration', () => {
@@ -427,16 +423,28 @@ describe('Trusted Folders', () => {
},
};
it('should return true when isHeadlessMode is true, ignoring config', async () => {
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
isTrusted: undefined,
source: undefined,
});
});
it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => {
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
try {
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
source: 'env',
});
} finally {
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
}
});
it('should fall back to config when isHeadlessMode is false', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
@@ -449,12 +457,12 @@ describe('Trusted Folders', () => {
);
});
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
});
});
+31 -365
View File
@@ -4,330 +4,29 @@
* 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 {
FatalConfigError,
getErrorMessage,
isWithinRoot,
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
type HeadlessModeOptions,
checkPathTrust,
isHeadlessMode,
loadTrustedFolders as loadCoreTrustedFolders,
type LoadedTrustedFolders,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export {
TrustLevel,
isTrustLevel,
resetTrustedFoldersForTesting,
saveTrustedFolders,
} from '@google/gemini-cli-core';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
return path.join(homedir(), GEMINI_DIR);
}
export function getTrustedFoldersPath(): string {
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
}
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
}
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export function isTrustLevel(
value: string | number | boolean | object | null | undefined,
): value is TrustLevel {
return (
typeof value === 'string' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(TrustLevel).includes(value as TrustLevel)
);
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | undefined;
}
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* 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". This function
* should only be invoked when the folder trust setting is active.
*
* @param location path
* @returns
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): boolean | undefined {
if (isHeadlessMode(headlessOptions)) {
return true;
}
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
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);
if (isWithinRoot(realLocation, realEffectivePath)) {
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 fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = 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 = getTrustedFoldersPath();
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
errors.push({
message: 'Trusted folders file is not a valid JSON object.',
path: userPath,
});
} else {
for (const [path, trustLevel] of Object.entries(parsed)) {
if (isTrustLevel(trustLevel)) {
userConfig[path] = trustLevel;
} else {
const possibleValues = Object.values(TrustLevel).join(', ');
errors.push({
message: `Invalid trust level "${trustLevel}" for path "${path}". 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',
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;
}
}
export type {
TrustRule,
TrustedFoldersError,
TrustedFoldersFile,
TrustResult,
LoadedTrustedFolders,
} from '@google/gemini-cli-core';
/** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean {
@@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
return folderTrustSetting;
}
function getWorkspaceTrustFromLocalConfig(
workspaceDir: string,
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
const folders = loadTrustedFolders();
const configToUse = trustConfig ?? folders.user.config;
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(
workspaceDir,
configToUse,
headlessOptions,
);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
};
export function loadTrustedFolders(): LoadedTrustedFolders {
return loadCoreTrustedFolders();
}
/**
* Returns true or false if the workspace is considered "trusted".
*/
export function isWorkspaceTrusted(
settings: Settings,
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
if (isHeadlessMode(headlessOptions)) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
if (ideTrust !== undefined) {
return { isTrusted: ideTrust, source: 'ide' };
}
// Fall back to the local user configuration
return getWorkspaceTrustFromLocalConfig(
workspaceDir,
trustConfig,
headlessOptions,
);
): {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | 'env' | undefined;
} {
return checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: isFolderTrustEnabled(settings),
isHeadless: isHeadlessMode(headlessOptions),
});
}
+3
View File
@@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => {
vi.stubEnv('GEMINI_SANDBOX', '');
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SHPOOL_SESSION_NAME', '');
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
@@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
});
await act(async () => {
@@ -613,6 +615,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
});
await act(async () => {
+6
View File
@@ -644,6 +644,12 @@ export async function main() {
cliStartupHandle?.end();
if (!config.isInteractive()) {
for (const warning of startupWarnings) {
writeToStderr(warning.message + '\n');
}
}
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
// Earlier initialization phases (like TerminalCapabilityManager resolving
+1
View File
@@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
});
afterEach(() => {
@@ -34,6 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
Low: 'low',
High: 'high',
@@ -143,6 +144,51 @@ describe('getUserStartupWarnings', () => {
});
});
describe('folder trust check', () => {
it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => {
const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import(
'@google/gemini-cli-core'
);
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockImplementation(() => {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory',
);
});
vi.mocked(isHeadlessMode).mockReturnValue(true);
await expect(
getUserStartupWarnings({}, testRootDir),
).rejects.toThrowError(FatalUntrustedWorkspaceError);
});
it('should not return a warning when trusted in headless mode', async () => {
const { isHeadlessMode } = await import('@google/gemini-cli-core');
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(isHeadlessMode).mockReturnValue(true);
const warnings = await getUserStartupWarnings({}, testRootDir);
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
});
it('should not return a warning when untrusted in interactive mode', async () => {
const { isHeadlessMode } = await import('@google/gemini-cli-core');
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
vi.mocked(isHeadlessMode).mockReturnValue(false);
const warnings = await getUserStartupWarnings({}, testRootDir);
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
});
});
describe('compatibility warnings', () => {
it('should include compatibility warnings by default', async () => {
const compWarning = {
@@ -12,6 +12,8 @@ import {
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
isHeadlessMode,
FatalUntrustedWorkspaceError,
} from '@google/gemini-cli-core';
import type { Settings } from '../config/settingsSchema.js';
import {
@@ -79,10 +81,34 @@ const rootDirectoryCheck: WarningCheck = {
},
};
const folderTrustCheck: WarningCheck = {
id: 'folder-trust',
priority: WarningPriority.High,
check: async (workspaceRoot: string, settings: Settings) => {
if (!isFolderTrustEnabled(settings)) {
return null;
}
const { isTrusted } = isWorkspaceTrusted(settings, workspaceRoot);
if (isTrusted === true) {
return null;
}
if (isHeadlessMode()) {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.',
);
}
return null;
},
};
// All warning checks
const WARNING_CHECKS: readonly WarningCheck[] = [
homeDirectoryCheck,
rootDirectoryCheck,
folderTrustCheck,
];
export async function getUserStartupWarnings(
+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';
@@ -78,6 +79,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;
}
}