From 6e08c33def1b1b20b6ce03a0f144e1757d5ce2da Mon Sep 17 00:00:00 2001 From: galz10 Date: Mon, 20 Apr 2026 12:25:32 -0700 Subject: [PATCH 1/9] feat(cli): secure .env loading in untrusted workspaces --- package-lock.json | 85 ++-- packages/cli/src/config/config.ts | 2 +- packages/cli/src/config/settings.test.ts | 5 +- packages/cli/src/config/settings.ts | 4 +- .../cli/src/config/trustedFolders.test.ts | 2 + packages/cli/src/config/trustedFolders.ts | 393 ++---------------- packages/core/src/config/storage.ts | 8 + packages/core/src/index.ts | 3 + packages/core/src/utils/paths.ts | 1 + packages/core/src/utils/trust.test.ts | 174 ++++++++ packages/core/src/utils/trust.ts | 356 ++++++++++++++++ repro_env_trust.js | 51 +++ 12 files changed, 661 insertions(+), 423 deletions(-) create mode 100644 packages/core/src/utils/trust.test.ts create mode 100644 packages/core/src/utils/trust.ts create mode 100644 repro_env_trust.js diff --git a/package-lock.json b/package-lock.json index edc96948c48..1cc9f974df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -449,7 +449,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1473,6 +1474,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" @@ -2150,6 +2152,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2330,6 +2333,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2379,6 +2383,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2753,6 +2758,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2786,6 +2792,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2840,6 +2847,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4046,6 +4054,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4319,6 +4328,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -4593,56 +4603,6 @@ } } }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.47.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -5113,6 +5073,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7190,7 +7151,8 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7775,6 +7737,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8292,6 +8255,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9558,6 +9522,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9817,6 +9782,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz", "integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==", "license": "MIT", + "peer": true, "dependencies": { "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.3", @@ -13530,6 +13496,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13540,6 +13507,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15659,6 +15627,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15881,7 +15850,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15889,6 +15859,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16054,6 +16025,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16121,6 +16093,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -16507,6 +16480,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17077,6 +17051,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17089,6 +17064,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17727,6 +17703,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18161,6 +18138,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -18264,6 +18242,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d3b807f9917..c1a8854b87a 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -544,7 +544,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; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a58b9889a2a..45bd7ea9f1a 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -1970,7 +1970,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 +1978,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', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 40d275e79ef..bc9c9d5eed5 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -592,8 +592,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; } diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 2741da875fe..a43796d9b1b 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -33,8 +33,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { homedir: () => '/mock/home/user', isHeadlessMode: vi.fn(() => false), coreEvents: { + ...actual.coreEvents, emitFeedback: vi.fn(), }, + FatalConfigError: actual.FatalConfigError, }; }); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 761bc368d3f..d74ccf77986 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -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 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; - path: string; -} - -export interface TrustResult { - isTrusted: boolean | undefined; - source: 'ide' | 'file' | undefined; -} - -const realPathCache = new Map(); - -/** - * 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, - 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 { - 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; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - config = parseTrustedFoldersJson(content) as Record; - } 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 = {}; - - 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; - - 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()}`; +export { + TrustLevel, + isTrustLevel, + resetTrustedFoldersForTesting, + saveTrustedFolders, +} from '@google/gemini-cli-core'; - 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,21 @@ export function isFolderTrustEnabled(settings: Settings): boolean { return folderTrustSetting; } -function getWorkspaceTrustFromLocalConfig( - workspaceDir: string, - trustConfig?: Record, - 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(isHeadlessMode()); } +/** + * Returns true or false if the workspace is considered "trusted". + */ export function isWorkspaceTrusted( settings: Settings, workspaceDir: string = process.cwd(), - trustConfig?: Record, 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' | undefined } { + return checkPathTrust({ + path: workspaceDir, + isFolderTrustEnabled: isFolderTrustEnabled(settings), + isHeadless: isHeadlessMode(headlessOptions), + }); } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 5e3aada4e57..5a40648a4a5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -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'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62a0b127bde..3123dd90964 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index dae7c5c4e8e..fee8b8d8556 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -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. diff --git a/packages/core/src/utils/trust.test.ts b/packages/core/src/utils/trust.test.ts new file mode 100644 index 00000000000..cebe7b214cd --- /dev/null +++ b/packages/core/src/utils/trust.test.ts @@ -0,0 +1,174 @@ +/** + * @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'; + +vi.mock('proper-lockfile'); +vi.mock('./headless.js', async (importOriginal) => { + const original = await importOriginal(); + 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(); + }); + + 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 return trusted if headless mode is on', () => { + const result = checkPathTrust({ + path: '/any', + isFolderTrustEnabled: true, + isHeadless: true, + }); + expect(result).toEqual({ isTrusted: true, 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 }); + }); + }); +}); diff --git a/packages/core/src/utils/trust.ts b/packages/core/src/utils/trust.ts new file mode 100644 index 00000000000..9f8c96480a0 --- /dev/null +++ b/packages/core/src/utils/trust.ts @@ -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 } from './errors.js'; +import { coreEvents } from './events.js'; +import { ideContextStore } from '../ide/ideContext.js'; + + +const { promises: fsPromises } = fs; + +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' | 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).includes(value as TrustLevel) + ); +} + +/** + * 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 (options.isHeadless) { + return { isTrusted: true, source: undefined }; + } + + 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(options.isHeadless); + + 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; + path: string; +} + +const realPathCache = new Map(); + +/** + * 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[], + private readonly isHeadless?: boolean, + ) {} + + 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, + ): boolean | undefined { + if (this.isHeadless) { + return true; + } + + 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 { + 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; + try { + config = parseTrustedFoldersJson(content) as Record; + } catch (error) { + coreEvents.emitFeedback( + 'error', + `Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`, + error, + ); + config = {}; + } + + // Use normalized path as key + const normalizedPath = normalizePath(folderPath); + const originalTrustLevel = config[normalizedPath]; + 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(isHeadless?: boolean): LoadedTrustedFolders { + if (loadedTrustedFolders) { + return loadedTrustedFolders; + } + + const errors: TrustedFoldersError[] = []; + const userConfig: Record = {}; + + const userPath = Storage.getTrustedFoldersPath(); + try { + if (fs.existsSync(userPath)) { + const content = fs.readFileSync(userPath, 'utf-8'); + const parsed = parseTrustedFoldersJson(content) as Record; + + 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 [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: error instanceof Error ? error.message : String(error), + path: userPath, + }); + } + + loadedTrustedFolders = new LoadedTrustedFolders( + { path: userPath, config: userConfig }, + errors, + isHeadless, + ); + 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; + } +} diff --git a/repro_env_trust.js b/repro_env_trust.js new file mode 100644 index 00000000000..b45995a9de5 --- /dev/null +++ b/repro_env_trust.js @@ -0,0 +1,51 @@ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadSettings } from './packages/cli/src/config/settings.js'; +import { resetTrustedFoldersForTesting } from '@google/gemini-cli-core'; + +async function runReproduction() { + const tmpDir = path.join(process.cwd(), 'repro_untrusted'); + const geminiDir = path.join(tmpDir, '.gemini'); + const envFile = path.join(geminiDir, '.env'); + + // 1. Setup untrusted workspace + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + } + fs.mkdirSync(geminiDir, { recursive: true }); + + // 2. Set a unique environment variable in .gemini/.env + const SECRET_KEY = 'REPRO_SECRET_123'; + fs.writeFileSync(envFile, `REPRO_VARIABLE=${SECRET_KEY}\n`); + + // 3. Ensure the folder is NOT trusted + resetTrustedFoldersForTesting(); + + console.log('--- Reproduction Start ---'); + console.log(`Workspace: ${tmpDir}`); + console.log(`Environment variable REPRO_VARIABLE before load: ${process.env['REPRO_VARIABLE']}`); + + // 4. Load settings (this triggers loadEnvironment) + // We mock the process.cwd() to the untrusted directory + const originalCwd = process.cwd(); + process.chdir(tmpDir); + + try { + const settings = loadSettings(); + console.log(`Trust Status: ${settings.isTrusted}`); + console.log(`Environment variable REPRO_VARIABLE after load: ${process.env['REPRO_VARIABLE']}`); + + if (process.env['REPRO_VARIABLE'] === SECRET_KEY) { + console.log('RESULT: .env was LOADED in untrusted workspace.'); + } else { + console.log('RESULT: .env was NOT loaded (or was restricted).'); + } + } finally { + process.chdir(originalCwd); + // Cleanup + // fs.rmSync(tmpDir, { recursive: true }); + } +} + +runReproduction().catch(console.error); From 536ba945fea105c15530f9d389641ba19f1d8857 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 22 Apr 2026 08:07:40 -0700 Subject: [PATCH 2/9] chore: refactor configuration and startup logic --- packages/cli/src/config/config.ts | 11 +++- packages/cli/src/config/settings.ts | 34 +++++++------ .../cli/src/config/trustedFolders.test.ts | 49 ++++++++---------- packages/cli/src/config/trustedFolders.ts | 4 +- packages/cli/src/gemini.test.tsx | 2 + packages/cli/src/gemini.tsx | 6 +++ .../cli/src/utils/userStartupWarnings.test.ts | 48 +++++++++++++++++ packages/cli/src/utils/userStartupWarnings.ts | 23 +++++++++ .../sandbox/linux/bwrapArgsBuilder.test.ts | 8 +-- .../src/sandbox/linux/bwrapArgsBuilder.ts | 12 +++-- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 13 +++++ packages/core/src/utils/trust.test.ts | 27 +++++++++- packages/core/src/utils/trust.ts | 33 +++++------- repro_env_trust.js | 51 ------------------- 14 files changed, 193 insertions(+), 128 deletions(-) delete mode 100644 repro_env_trust.js diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c1a8854b87a..113b8f27894 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -105,6 +105,7 @@ export interface CliArgs { startupMessages?: string[]; rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; + skipTrust: boolean | undefined; isCommand: boolean | undefined; } @@ -288,6 +289,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,7 +462,10 @@ export async function parseArguments( yargsInstance.wrap(yargsInstance.terminalWidth()); let result; try { - result = await yargsInstance.parse(); + result = await yargsInstance.parse() as Record; + if (result['skip-trust']) { + process.env['GEMINI_TRUST_WORKSPACE'] = 'true'; + } } catch (e) { const msg = e instanceof Error ? e.message : String(e); debugLogger.error(msg); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index bc9c9d5eed5..f662f000749 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -494,26 +494,30 @@ 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'); + const envPath = path.join(currentDir, ".env"); if (fs.existsSync(envPath)) { return envPath; } 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'); + const homeEnvPath = path.join(homedir(), ".env"); if (fs.existsSync(homeEnvPath)) { return homeEnvPath; } @@ -554,27 +558,27 @@ 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 // sandbox flag before the full command-line parser is initialized to ensure // security constraints are applied when loading environment variables. const args = process.argv.slice(2); - const doubleDashIndex = args.indexOf('--'); + const doubleDashIndex = args.indexOf("--"); const relevantArgs = doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex); const isSandboxed = !!settings.tools?.sandbox || - relevantArgs.includes('-s') || - relevantArgs.includes('--sandbox'); + relevantArgs.includes("-s") || + relevantArgs.includes("--sandbox"); // Cloud Shell environment variable handling - if (process.env['CLOUD_SHELL'] === 'true') { + if (process.env["CLOUD_SHELL"] === "true") { setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed); } diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index a43796d9b1b..910fccefb05 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -11,7 +11,7 @@ import * as os from 'node:os'; import { FatalConfigError, ideContextStore, - coreEvents, + // coreEvents, } from '@google/gemini-cli-core'; import { loadTrustedFolders, @@ -32,10 +32,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => '/mock/home/user', isHeadlessMode: vi.fn(() => false), - coreEvents: { - ...actual.coreEvents, + coreEvents: Object.assign(Object.create(Object.getPrototypeOf(actual.coreEvents)), actual.coreEvents, { emitFeedback: vi.fn(), - }, + }), FatalConfigError: actual.FatalConfigError, }; }); @@ -240,27 +239,7 @@ describe('Trusted Folders', () => { ).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 }); - }); + // Removed redundant test covered in core }); describe('isWorkspaceTrusted Integration', () => { @@ -429,16 +408,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_TRUST_WORKSPACE is true', async () => { + process.env['GEMINI_TRUST_WORKSPACE'] = 'true'; + try { + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'env', + }); + } finally { + delete process.env['GEMINI_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); @@ -451,12 +442,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); }); }); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index d74ccf77986..c1ea71b94d8 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -35,7 +35,7 @@ export function isFolderTrustEnabled(settings: Settings): boolean { } export function loadTrustedFolders(): LoadedTrustedFolders { - return loadCoreTrustedFolders(isHeadlessMode()); + return loadCoreTrustedFolders(); } /** @@ -45,7 +45,7 @@ export function isWorkspaceTrusted( settings: Settings, workspaceDir: string = process.cwd(), headlessOptions?: HeadlessModeOptions, -): { isTrusted: boolean | undefined; source: 'ide' | 'file' | undefined } { +): { isTrusted: boolean | undefined; source: 'ide' | 'file' | 'env' | undefined } { return checkPathTrust({ path: workspaceDir, isFolderTrustEnabled: isFolderTrustEnabled(settings), diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5b31d153fe9..59e766a839f 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -555,6 +555,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + skipTrust: undefined, }); await act(async () => { @@ -613,6 +614,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + skipTrust: undefined, }); await act(async () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index eedfcc950ad..409422c8090 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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 diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 41ed0611664..3b19ad3eacd 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -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,53 @@ describe('getUserStartupWarnings', () => { }); }); + describe('folder trust check', () => { + it('should return a warning when untrusted in headless 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(true); + + const warnings = await getUserStartupWarnings({}, testRootDir); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'folder-trust', + message: expect.stringContaining('This folder is currently untrusted'), + priority: WarningPriority.High, + }), + ); + }); + + 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 = { diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index 5575582fab3..055fbe1e3c4 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -12,6 +12,7 @@ import { getCompatibilityWarnings, WarningPriority, type StartupWarning, + isHeadlessMode, } from '@google/gemini-cli-core'; import type { Settings } from '../config/settingsSchema.js'; import { @@ -79,10 +80,32 @@ 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()) { + return 'This folder is currently untrusted, to run gemini-cli in this folder please trust the folder ensure that you review it carefully, and provide the command: gemini-cli --skip-trust'; + } + + return null; + }, +}; + // All warning checks const WARNING_CHECKS: readonly WarningCheck[] = [ homeDirectoryCheck, rootDirectoryCheck, + folderTrustCheck, ]; export async function getUserStartupWarnings( diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index 45571f066f9..196bd72cdaa 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -396,7 +396,7 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { expect(args[args.indexOf(worktreeGitDir) - 1]).toBe('--ro-bind-try'); }); - it('git worktree read-only bindings should override previous policyWrite bindings', async () => { + it('explicit policyWrite bindings should override git worktree read-only bindings', async () => { const worktreeGitDir = '/custom/worktree/.git'; const args = await buildBwrapArgs({ @@ -409,11 +409,11 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { }), }); - const writeBindIndex = args.indexOf('/custom/worktree'); - const worktreeBindIndex = args.lastIndexOf(worktreeGitDir); + const writeBindIndex = args.lastIndexOf('/custom/worktree'); + const worktreeBindIndex = args.indexOf(worktreeGitDir); expect(writeBindIndex).toBeGreaterThan(-1); expect(worktreeBindIndex).toBeGreaterThan(-1); - expect(worktreeBindIndex).toBeGreaterThan(writeBindIndex); + expect(writeBindIndex).toBeGreaterThan(worktreeBindIndex); }); }); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index 14301bb8884..0cc861a7920 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -116,11 +116,6 @@ export async function buildBwrapArgs( mounts.push({ type: '--ro-bind-try', src: p, dest: p }); } - // Collect explicit additional write permissions. - for (const p of resolvedPaths.policyWrite) { - mounts.push({ type: '--bind-try', src: p, dest: p }); - } - const policyWriteKeys = new Set(resolvedPaths.policyWrite.map(toPathKey)); for (const file of GOVERNANCE_FILES) { @@ -162,6 +157,13 @@ export async function buildBwrapArgs( } } + // Apply explicit additional write permissions. + // We apply these after GOVERNANCE_FILES and gitWorktree rules so that if a user explicitly requests + // write access to a protected directory (like .git), the explicit allow takes precedence in bwrap. + for (const p of resolvedPaths.policyWrite) { + bwrapArgs.push('--bind-try', p, p); + } + for (const p of resolvedPaths.forbidden) { if (!fs.existsSync(p)) continue; try { diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index 39d5cbe6fd4..cec02570500 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -159,6 +159,19 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { ); const realGovernanceFile = resolveToRealPath(governanceFile); + // Skip deny if explicitly allowed by additional write permissions + const isExplicitlyAllowed = resolvedPaths.policyWrite.some( + (p) => + p === governanceFile || + p === realGovernanceFile || + governanceFile.startsWith(p + path.sep) || + realGovernanceFile.startsWith(p + path.sep), + ); + + if (isExplicitlyAllowed) { + continue; + } + // Determine if it should be treated as a directory (subpath) or a file (literal). // .git is generally a directory, while ignore files are literals. let isDirectory = GOVERNANCE_FILES[i].isDirectory; diff --git a/packages/core/src/utils/trust.test.ts b/packages/core/src/utils/trust.test.ts index cebe7b214cd..e65e43a914b 100644 --- a/packages/core/src/utils/trust.test.ts +++ b/packages/core/src/utils/trust.test.ts @@ -18,6 +18,7 @@ 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) => { @@ -122,13 +123,13 @@ describe('Trust Utility (Core)', () => { }); describe('checkPathTrust', () => { - it('should return trusted if headless mode is on', () => { + 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: true, source: undefined }); + expect(result).toEqual({ isTrusted: undefined, source: undefined }); }); it('should return trusted if folder trust is disabled', () => { @@ -171,4 +172,26 @@ describe('Trust Utility (Core)', () => { 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), + ); + }); + }); }); diff --git a/packages/core/src/utils/trust.ts b/packages/core/src/utils/trust.ts index 9f8c96480a0..1d32b469a4c 100644 --- a/packages/core/src/utils/trust.ts +++ b/packages/core/src/utils/trust.ts @@ -26,7 +26,7 @@ export enum TrustLevel { export interface TrustResult { isTrusted: boolean | undefined; - source: 'ide' | 'file' | undefined; + source: 'ide' | 'file' | 'env' | undefined; } export interface TrustOptions { @@ -38,7 +38,7 @@ export interface TrustOptions { export function isTrustLevel(value: unknown): value is TrustLevel { return ( typeof value === 'string' && - Object.values(TrustLevel).includes(value as TrustLevel) + Object.values(TrustLevel).some((v) => v === value) ); } @@ -47,8 +47,8 @@ export function isTrustLevel(value: unknown): value is TrustLevel { * IDE context, and local configuration file. */ export function checkPathTrust(options: TrustOptions): TrustResult { - if (options.isHeadless) { - return { isTrusted: true, source: undefined }; + if (process.env['GEMINI_TRUST_WORKSPACE'] === 'true') { + return { isTrusted: true, source: 'env' }; } if (!options.isFolderTrustEnabled) { @@ -60,7 +60,7 @@ export function checkPathTrust(options: TrustOptions): TrustResult { return { isTrusted: ideTrust, source: 'ide' }; } - const folders = loadTrustedFolders(options.isHeadless); + const folders = loadTrustedFolders(); if (folders.errors.length > 0) { const errorMessages = folders.errors.map( @@ -130,7 +130,6 @@ export class LoadedTrustedFolders { constructor( readonly user: TrustedFoldersFile, readonly errors: TrustedFoldersError[], - private readonly isHeadless?: boolean, ) {} get rules(): TrustRule[] { @@ -151,9 +150,7 @@ export class LoadedTrustedFolders { location: string, config?: Record, ): boolean | undefined { - if (this.isHeadless) { - return true; - } + const configToUse = config ?? this.user.config; @@ -222,6 +219,9 @@ export class LoadedTrustedFolders { }, }); + const normalizedPath = normalizePath(folderPath); + const originalTrustLevel = this.user.config[normalizedPath]; + try { // Re-read the file to handle concurrent updates const content = await fsPromises.readFile(this.user.path, 'utf-8'); @@ -238,8 +238,6 @@ export class LoadedTrustedFolders { } // Use normalized path as key - const normalizedPath = normalizePath(folderPath); - const originalTrustLevel = config[normalizedPath]; config[normalizedPath] = trustLevel; this.user.config[normalizedPath] = trustLevel; @@ -271,7 +269,7 @@ export function resetTrustedFoldersForTesting(): void { clearRealPathCacheForTesting(); } -export function loadTrustedFolders(isHeadless?: boolean): LoadedTrustedFolders { +export function loadTrustedFolders(): LoadedTrustedFolders { if (loadedTrustedFolders) { return loadedTrustedFolders; } @@ -283,7 +281,7 @@ export function loadTrustedFolders(isHeadless?: boolean): LoadedTrustedFolders { try { if (fs.existsSync(userPath)) { const content = fs.readFileSync(userPath, 'utf-8'); - const parsed = parseTrustedFoldersJson(content) as Record; + const parsed = parseTrustedFoldersJson(content); if ( typeof parsed !== 'object' || @@ -295,7 +293,8 @@ export function loadTrustedFolders(isHeadless?: boolean): LoadedTrustedFolders { path: userPath, }); } else { - for (const [rawPath, trustLevel] of Object.entries(parsed)) { + const config = parsed as Record; + for (const [rawPath, trustLevel] of Object.entries(config)) { const normalizedPath = normalizePath(rawPath); if (isTrustLevel(trustLevel)) { userConfig[normalizedPath] = trustLevel; @@ -316,11 +315,7 @@ export function loadTrustedFolders(isHeadless?: boolean): LoadedTrustedFolders { }); } - loadedTrustedFolders = new LoadedTrustedFolders( - { path: userPath, config: userConfig }, - errors, - isHeadless, - ); + loadedTrustedFolders = new LoadedTrustedFolders({ path: userPath, config: userConfig }, errors); return loadedTrustedFolders; } diff --git a/repro_env_trust.js b/repro_env_trust.js deleted file mode 100644 index b45995a9de5..00000000000 --- a/repro_env_trust.js +++ /dev/null @@ -1,51 +0,0 @@ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { loadSettings } from './packages/cli/src/config/settings.js'; -import { resetTrustedFoldersForTesting } from '@google/gemini-cli-core'; - -async function runReproduction() { - const tmpDir = path.join(process.cwd(), 'repro_untrusted'); - const geminiDir = path.join(tmpDir, '.gemini'); - const envFile = path.join(geminiDir, '.env'); - - // 1. Setup untrusted workspace - if (fs.existsSync(tmpDir)) { - fs.rmSync(tmpDir, { recursive: true }); - } - fs.mkdirSync(geminiDir, { recursive: true }); - - // 2. Set a unique environment variable in .gemini/.env - const SECRET_KEY = 'REPRO_SECRET_123'; - fs.writeFileSync(envFile, `REPRO_VARIABLE=${SECRET_KEY}\n`); - - // 3. Ensure the folder is NOT trusted - resetTrustedFoldersForTesting(); - - console.log('--- Reproduction Start ---'); - console.log(`Workspace: ${tmpDir}`); - console.log(`Environment variable REPRO_VARIABLE before load: ${process.env['REPRO_VARIABLE']}`); - - // 4. Load settings (this triggers loadEnvironment) - // We mock the process.cwd() to the untrusted directory - const originalCwd = process.cwd(); - process.chdir(tmpDir); - - try { - const settings = loadSettings(); - console.log(`Trust Status: ${settings.isTrusted}`); - console.log(`Environment variable REPRO_VARIABLE after load: ${process.env['REPRO_VARIABLE']}`); - - if (process.env['REPRO_VARIABLE'] === SECRET_KEY) { - console.log('RESULT: .env was LOADED in untrusted workspace.'); - } else { - console.log('RESULT: .env was NOT loaded (or was restricted).'); - } - } finally { - process.chdir(originalCwd); - // Cleanup - // fs.rmSync(tmpDir, { recursive: true }); - } -} - -runReproduction().catch(console.error); From 06bf453628d9c4c5a6cc08bead92db94520eab25 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 22 Apr 2026 13:42:20 -0400 Subject: [PATCH 3/9] throw an error for untrusted folders in headless mode --- packages/cli/src/config/config.ts | 7 ++-- packages/cli/src/config/settings.ts | 16 ++++---- .../cli/src/config/trustedFolders.test.ts | 18 ++++----- packages/cli/src/config/trustedFolders.ts | 5 ++- packages/cli/src/gemini.tsx | 2 +- .../cli/src/utils/userStartupWarnings.test.ts | 24 ++++++------ packages/cli/src/utils/userStartupWarnings.ts | 5 ++- .../sandbox/linux/bwrapArgsBuilder.test.ts | 8 ++-- .../src/sandbox/linux/bwrapArgsBuilder.ts | 12 +++--- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 13 ------- packages/core/src/utils/errors.ts | 6 +++ packages/core/src/utils/trust.test.ts | 17 +++++++-- packages/core/src/utils/trust.ts | 37 ++++++++++--------- 13 files changed, 87 insertions(+), 83 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 113b8f27894..617921b5317 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -462,7 +462,7 @@ export async function parseArguments( yargsInstance.wrap(yargsInstance.terminalWidth()); let result; try { - result = await yargsInstance.parse() as Record; + result = (await yargsInstance.parse()) as Record; if (result['skip-trust']) { process.env['GEMINI_TRUST_WORKSPACE'] = 'true'; } @@ -481,7 +481,6 @@ 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(' ') @@ -500,8 +499,8 @@ export async function parseArguments( } // Keep CliArgs.query as a string for downstream typing - (result as Record)['query'] = q || undefined; - (result as Record)['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 diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index f662f000749..0d61b66d876 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -499,12 +499,12 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null { while (true) { // prefer gemini-specific .env under GEMINI_DIR if (isTrusted) { - const geminiEnvPath = path.join(currentDir, GEMINI_DIR, ".env"); + const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); if (fs.existsSync(geminiEnvPath)) { return geminiEnvPath; } } - const envPath = path.join(currentDir, ".env"); + const envPath = path.join(currentDir, '.env'); if (fs.existsSync(envPath)) { return envPath; } @@ -512,12 +512,12 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null { if (parentDir === currentDir || !parentDir) { // check .env under home as fallback, again preferring gemini-specific .env if (isTrusted) { - const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, ".env"); + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); if (fs.existsSync(homeGeminiEnvPath)) { return homeGeminiEnvPath; } } - const homeEnvPath = path.join(homedir(), ".env"); + const homeEnvPath = path.join(homedir(), '.env'); if (fs.existsSync(homeEnvPath)) { return homeEnvPath; } @@ -568,17 +568,17 @@ export function loadEnvironment( // sandbox flag before the full command-line parser is initialized to ensure // security constraints are applied when loading environment variables. const args = process.argv.slice(2); - const doubleDashIndex = args.indexOf("--"); + const doubleDashIndex = args.indexOf('--'); const relevantArgs = doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex); const isSandboxed = !!settings.tools?.sandbox || - relevantArgs.includes("-s") || - relevantArgs.includes("--sandbox"); + relevantArgs.includes('-s') || + relevantArgs.includes('--sandbox'); // Cloud Shell environment variable handling - if (process.env["CLOUD_SHELL"] === "true") { + if (process.env['CLOUD_SHELL'] === 'true') { setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed); } diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 910fccefb05..8b53084df9f 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -8,11 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { - FatalConfigError, - ideContextStore, - // coreEvents, -} from '@google/gemini-cli-core'; +import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core'; import { loadTrustedFolders, TrustLevel, @@ -32,9 +28,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => '/mock/home/user', isHeadlessMode: vi.fn(() => false), - coreEvents: Object.assign(Object.create(Object.getPrototypeOf(actual.coreEvents)), actual.coreEvents, { - emitFeedback: vi.fn(), - }), + coreEvents: Object.assign( + Object.create(Object.getPrototypeOf(actual.coreEvents)), + actual.coreEvents, + { + emitFeedback: vi.fn(), + }, + ), FatalConfigError: actual.FatalConfigError, }; }); @@ -238,8 +238,6 @@ describe('Trusted Folders', () => { loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER), ).rejects.toThrow(FatalConfigError); }); - - // Removed redundant test covered in core }); describe('isWorkspaceTrusted Integration', () => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index c1ea71b94d8..f901ed13db5 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -45,7 +45,10 @@ export function isWorkspaceTrusted( settings: Settings, workspaceDir: string = process.cwd(), headlessOptions?: HeadlessModeOptions, -): { isTrusted: boolean | undefined; source: 'ide' | 'file' | 'env' | undefined } { +): { + isTrusted: boolean | undefined; + source: 'ide' | 'file' | 'env' | undefined; +} { return checkPathTrust({ path: workspaceDir, isFolderTrustEnabled: isFolderTrustEnabled(settings), diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 409422c8090..2816c37466e 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -646,7 +646,7 @@ export async function main() { if (!config.isInteractive()) { for (const warning of startupWarnings) { - writeToStderr(warning.message + "\n"); + writeToStderr(warning.message + '\n'); } } diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 3b19ad3eacd..120ac36c3b1 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -145,23 +145,21 @@ describe('getUserStartupWarnings', () => { }); describe('folder trust check', () => { - it('should return a warning when untrusted in headless mode', async () => { - const { isHeadlessMode } = await import('@google/gemini-cli-core'); + 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).mockReturnValue({ - isTrusted: false, - source: undefined, + vi.mocked(isWorkspaceTrusted).mockImplementation(() => { + throw new FatalUntrustedWorkspaceError( + 'Gemini CLI is not running in a trusted directory', + ); }); vi.mocked(isHeadlessMode).mockReturnValue(true); - const warnings = await getUserStartupWarnings({}, testRootDir); - expect(warnings).toContainEqual( - expect.objectContaining({ - id: 'folder-trust', - message: expect.stringContaining('This folder is currently untrusted'), - priority: WarningPriority.High, - }), - ); + await expect( + getUserStartupWarnings({}, testRootDir), + ).rejects.toThrowError(FatalUntrustedWorkspaceError); }); it('should not return a warning when trusted in headless mode', async () => { diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index 055fbe1e3c4..926608e8590 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -13,6 +13,7 @@ import { WarningPriority, type StartupWarning, isHeadlessMode, + FatalUntrustedWorkspaceError, } from '@google/gemini-cli-core'; import type { Settings } from '../config/settingsSchema.js'; import { @@ -94,7 +95,9 @@ const folderTrustCheck: WarningCheck = { } if (isHeadlessMode()) { - return 'This folder is currently untrusted, to run gemini-cli in this folder please trust the folder ensure that you review it carefully, and provide the command: gemini-cli --skip-trust'; + throw new FatalUntrustedWorkspaceError( + 'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.', + ); } return null; diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index 196bd72cdaa..45571f066f9 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -396,7 +396,7 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { expect(args[args.indexOf(worktreeGitDir) - 1]).toBe('--ro-bind-try'); }); - it('explicit policyWrite bindings should override git worktree read-only bindings', async () => { + it('git worktree read-only bindings should override previous policyWrite bindings', async () => { const worktreeGitDir = '/custom/worktree/.git'; const args = await buildBwrapArgs({ @@ -409,11 +409,11 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { }), }); - const writeBindIndex = args.lastIndexOf('/custom/worktree'); - const worktreeBindIndex = args.indexOf(worktreeGitDir); + const writeBindIndex = args.indexOf('/custom/worktree'); + const worktreeBindIndex = args.lastIndexOf(worktreeGitDir); expect(writeBindIndex).toBeGreaterThan(-1); expect(worktreeBindIndex).toBeGreaterThan(-1); - expect(writeBindIndex).toBeGreaterThan(worktreeBindIndex); + expect(worktreeBindIndex).toBeGreaterThan(writeBindIndex); }); }); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index 0cc861a7920..14301bb8884 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -116,6 +116,11 @@ export async function buildBwrapArgs( mounts.push({ type: '--ro-bind-try', src: p, dest: p }); } + // Collect explicit additional write permissions. + for (const p of resolvedPaths.policyWrite) { + mounts.push({ type: '--bind-try', src: p, dest: p }); + } + const policyWriteKeys = new Set(resolvedPaths.policyWrite.map(toPathKey)); for (const file of GOVERNANCE_FILES) { @@ -157,13 +162,6 @@ export async function buildBwrapArgs( } } - // Apply explicit additional write permissions. - // We apply these after GOVERNANCE_FILES and gitWorktree rules so that if a user explicitly requests - // write access to a protected directory (like .git), the explicit allow takes precedence in bwrap. - for (const p of resolvedPaths.policyWrite) { - bwrapArgs.push('--bind-try', p, p); - } - for (const p of resolvedPaths.forbidden) { if (!fs.existsSync(p)) continue; try { diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index cec02570500..39d5cbe6fd4 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -159,19 +159,6 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { ); const realGovernanceFile = resolveToRealPath(governanceFile); - // Skip deny if explicitly allowed by additional write permissions - const isExplicitlyAllowed = resolvedPaths.policyWrite.some( - (p) => - p === governanceFile || - p === realGovernanceFile || - governanceFile.startsWith(p + path.sep) || - realGovernanceFile.startsWith(p + path.sep), - ); - - if (isExplicitlyAllowed) { - continue; - } - // Determine if it should be treated as a directory (subpath) or a file (literal). // .git is generally a directory, while ignore files are literals. let isDirectory = GOVERNANCE_FILES[i].isDirectory; diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 210902029bd..804e0745238 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -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 diff --git a/packages/core/src/utils/trust.test.ts b/packages/core/src/utils/trust.test.ts index e65e43a914b..386d2bdb221 100644 --- a/packages/core/src/utils/trust.test.ts +++ b/packages/core/src/utils/trust.test.ts @@ -30,12 +30,17 @@ vi.mock('./headless.js', async (importOriginal) => { }); describe('Trust Utility (Core)', () => { - const tempDir = path.join(os.tmpdir(), 'gemini-trust-test-' + Math.random().toString(36).slice(2)); + 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.spyOn(Storage, 'getTrustedFoldersPath').mockReturnValue( + trustedFoldersPath, + ); vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined)); vi.mocked(headless.isHeadlessMode).mockReturnValue(false); ideContextStore.clear(); @@ -93,7 +98,9 @@ describe('Trust Utility (Core)', () => { const folders = loadTrustedFolders(); expect(folders.isPathTrusted(path.resolve('/project/file.txt'))).toBe(true); - expect(folders.isPathTrusted(path.resolve('/project/.gemini/config.yaml'))).toBe(true); + expect( + folders.isPathTrusted(path.resolve('/project/.gemini/config.yaml')), + ).toBe(true); }); it('should save config correctly', async () => { @@ -101,7 +108,9 @@ describe('Trust Utility (Core)', () => { const testPath = path.resolve('/new/trusted/path'); await folders.setValue(testPath, TrustLevel.TRUST_FOLDER); - const savedContent = JSON.parse(fs.readFileSync(trustedFoldersPath, 'utf-8')); + 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; diff --git a/packages/core/src/utils/trust.ts b/packages/core/src/utils/trust.ts index 1d32b469a4c..15c2f71cc7d 100644 --- a/packages/core/src/utils/trust.ts +++ b/packages/core/src/utils/trust.ts @@ -15,9 +15,6 @@ import { FatalConfigError } from './errors.js'; import { coreEvents } from './events.js'; import { ideContextStore } from '../ide/ideContext.js'; - -const { promises: fsPromises } = fs; - export enum TrustLevel { TRUST_FOLDER = 'TRUST_FOLDER', TRUST_PARENT = 'TRUST_PARENT', @@ -102,6 +99,10 @@ function parseTrustedFoldersJson(content: string): unknown { return JSON.parse(stripJsonComments(content)); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + /** * FOR TESTING PURPOSES ONLY. * Clears the real path cache. @@ -150,8 +151,6 @@ export class LoadedTrustedFolders { location: string, config?: Record, ): boolean | undefined { - - const configToUse = config ?? this.user.config; // Resolve location to its realpath for canonical comparison @@ -224,17 +223,23 @@ export class LoadedTrustedFolders { try { // Re-read the file to handle concurrent updates - const content = await fsPromises.readFile(this.user.path, 'utf-8'); - let config: Record; + const content = await fs.promises.readFile(this.user.path, 'utf-8'); + const config: Record = {}; try { - config = parseTrustedFoldersJson(content) as Record; + 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, ); - config = {}; } // Use normalized path as key @@ -283,18 +288,13 @@ export function loadTrustedFolders(): LoadedTrustedFolders { const content = fs.readFileSync(userPath, 'utf-8'); const parsed = parseTrustedFoldersJson(content); - if ( - typeof parsed !== 'object' || - parsed === null || - Array.isArray(parsed) - ) { + if (!isRecord(parsed)) { errors.push({ message: 'Trusted folders file is not a valid JSON object.', path: userPath, }); } else { - const config = parsed as Record; - for (const [rawPath, trustLevel] of Object.entries(config)) { + for (const [rawPath, trustLevel] of Object.entries(parsed)) { const normalizedPath = normalizePath(rawPath); if (isTrustLevel(trustLevel)) { userConfig[normalizedPath] = trustLevel; @@ -315,7 +315,10 @@ export function loadTrustedFolders(): LoadedTrustedFolders { }); } - loadedTrustedFolders = new LoadedTrustedFolders({ path: userPath, config: userConfig }, errors); + loadedTrustedFolders = new LoadedTrustedFolders( + { path: userPath, config: userConfig }, + errors, + ); return loadedTrustedFolders; } From 1d6438abed53e598be5e0295bd16542cae7166f4 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 22 Apr 2026 13:57:47 -0400 Subject: [PATCH 4/9] change fsPromises to fs.promises --- packages/core/src/utils/trust.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/trust.ts b/packages/core/src/utils/trust.ts index 15c2f71cc7d..e7daa6b3643 100644 --- a/packages/core/src/utils/trust.ts +++ b/packages/core/src/utils/trust.ts @@ -201,12 +201,12 @@ export class LoadedTrustedFolders { const dirPath = path.dirname(this.user.path); if (!fs.existsSync(dirPath)) { - await fsPromises.mkdir(dirPath, { recursive: true }); + await fs.promises.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), { + await fs.promises.writeFile(this.user.path, JSON.stringify({}, null, 2), { mode: 0o600, }); } From 0bd9ba6efb280936d4b0a8e4cf45e54b44f05ca0 Mon Sep 17 00:00:00 2001 From: ehedlund Date: Wed, 22 Apr 2026 16:18:41 -0400 Subject: [PATCH 5/9] update unit tests --- packages/cli/src/config/extension.test.ts | 6 ++- packages/cli/src/config/settings.test.ts | 13 ++++-- .../cli/src/config/trustedFolders.test.ts | 40 +++++++++++++------ packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/gemini_cleanup.test.tsx | 1 + 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index ef7e61cf25c..c1aa276aadf 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -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])( diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 45bd7ea9f1a..af0e47b99fc 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -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 '{}'; }, diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 8b53084df9f..16a4cb98cb1 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core'; +import { + FatalConfigError, + ideContextStore, + normalizePath, +} from '@google/gemini-cli-core'; import { loadTrustedFolders, TrustLevel, @@ -71,8 +75,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]); @@ -81,8 +91,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, }); }); }); @@ -96,13 +106,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([]); }); @@ -144,14 +157,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([]); }); @@ -217,15 +230,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 () => { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 59e766a839f..09217a907b3 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => { vi.stubEnv('GEMINI_SANDBOX', ''); vi.stubEnv('SANDBOX', ''); vi.stubEnv('SHPOOL_SESSION_NAME', ''); + vi.stubEnv('GEMINI_TRUST_WORKSPACE', 'true'); initialUnhandledRejectionListeners = process.listeners('unhandledRejection'); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 2df1ab4d82c..77be712561d 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => { beforeEach(() => { vi.clearAllMocks(); process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + vi.stubEnv('GEMINI_TRUST_WORKSPACE', 'true'); }); afterEach(() => { From 686cd8113ff6a8111bed2180b6c2b7be0b2fe2ed Mon Sep 17 00:00:00 2001 From: ehedlund Date: Wed, 22 Apr 2026 16:31:27 -0400 Subject: [PATCH 6/9] ci: bypass workspace trust checks in workflows running integration and E2E tests --- .github/actions/run-tests/action.yml | 1 + .github/actions/verify-release/action.yml | 1 + .github/workflows/chained_e2e.yml | 3 +++ .github/workflows/ci.yml | 3 +++ .github/workflows/deflake.yml | 3 +++ .github/workflows/test-build-binary.yml | 1 + 6 files changed, 12 insertions(+) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 42fd78d7e93..e219b2207cc 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -28,6 +28,7 @@ runs: - name: 'Run Tests' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + GEMINI_TRUST_WORKSPACE: true working-directory: '${{ inputs.working-directory }}' run: |- echo "::group::Build" diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml index 4e0c6c6f725..a9550614312 100644 --- a/.github/actions/verify-release/action.yml +++ b/.github/actions/verify-release/action.yml @@ -98,6 +98,7 @@ runs: working-directory: '${{ inputs.working-directory }}' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + GEMINI_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 diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index e6385ad4bb9..c5667324721 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -167,6 +167,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_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_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_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a2bf9b6605..3e4d59b4a90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true + GEMINI_TRUST_WORKSPACE: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace "@google/gemini-cli" @@ -267,6 +268,7 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true + GEMINI_TRUST_WORKSPACE: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false @@ -430,6 +432,7 @@ jobs: env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' NO_COLOR: true + GEMINI_TRUST_WORKSPACE: true NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'test' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index cd61346ffad..7644c30fc73 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -62,6 +62,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_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_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_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml index d0069b8b152..11f59010f0c 100644 --- a/.github/workflows/test-build-binary.yml +++ b/.github/workflows/test-build-binary.yml @@ -141,6 +141,7 @@ jobs: if: "github.event_name != 'pull_request'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_TRUST_WORKSPACE: true run: | echo "Running integration tests with binary..." if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then From 70ec850361ab054b111fe213963a7a0567feb884 Mon Sep 17 00:00:00 2001 From: ehedlund Date: Wed, 22 Apr 2026 16:50:47 -0400 Subject: [PATCH 7/9] reset env variables for unit tests --- packages/cli/src/config/trustedFolders.test.ts | 1 + packages/core/src/utils/trust.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 16a4cb98cb1..85f3220a088 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -58,6 +58,7 @@ describe('Trusted Folders', () => { // Reset the internal state resetTrustedFoldersForTesting(); vi.clearAllMocks(); + delete process.env['GEMINI_TRUST_WORKSPACE']; }); afterEach(() => { diff --git a/packages/core/src/utils/trust.test.ts b/packages/core/src/utils/trust.test.ts index 386d2bdb221..b340f70e853 100644 --- a/packages/core/src/utils/trust.test.ts +++ b/packages/core/src/utils/trust.test.ts @@ -45,6 +45,7 @@ describe('Trust Utility (Core)', () => { vi.mocked(headless.isHeadlessMode).mockReturnValue(false); ideContextStore.clear(); resetTrustedFoldersForTesting(); + delete process.env['GEMINI_TRUST_WORKSPACE']; }); afterEach(() => { From 2afe2c11d2939c8e30a135fda83fd7bb4a33e82e Mon Sep 17 00:00:00 2001 From: davidapierce Date: Wed, 22 Apr 2026 21:35:45 +0000 Subject: [PATCH 8/9] Update docs related to this PR. --- docs/cli/cli-reference.md | 1 + docs/cli/trusted-folders.md | 24 ++++++++++++++++++++++++ docs/reference/configuration.md | 8 ++++++++ 3 files changed, 33 insertions(+) diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index e8217e226e6..41cc7661751 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -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.** | diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index cc4e880300f..3741d2cee8e 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -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_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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a5a6aa1eb25..033ac940938 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -2114,6 +2114,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_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 From 3303a20833b4964db222932095da2ebe1a933c25 Mon Sep 17 00:00:00 2001 From: ehedlund Date: Wed, 22 Apr 2026 19:37:37 -0400 Subject: [PATCH 9/9] address PR feedback --- .github/actions/run-tests/action.yml | 2 +- .github/actions/verify-release/action.yml | 2 +- .github/workflows/chained_e2e.yml | 6 ++--- .github/workflows/ci.yml | 6 ++--- .github/workflows/deflake.yml | 6 ++--- .github/workflows/test-build-binary.yml | 2 +- docs/cli/trusted-folders.md | 4 +-- docs/reference/configuration.md | 2 +- packages/cli/src/config/config.ts | 27 ++++++++++++------- .../cli/src/config/trustedFolders.test.ts | 8 +++--- packages/cli/src/gemini.test.tsx | 2 +- packages/cli/src/gemini_cleanup.test.tsx | 2 +- packages/cli/src/utils/userStartupWarnings.ts | 2 +- packages/core/src/utils/trust.test.ts | 2 +- packages/core/src/utils/trust.ts | 8 +++--- 15 files changed, 46 insertions(+), 35 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index e219b2207cc..e7fc63ce8b6 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -28,7 +28,7 @@ runs: - name: 'Run Tests' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true working-directory: '${{ inputs.working-directory }}' run: |- echo "::group::Build" diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml index a9550614312..d3d1d075d24 100644 --- a/.github/actions/verify-release/action.yml +++ b/.github/actions/verify-release/action.yml @@ -98,7 +98,7 @@ runs: working-directory: '${{ inputs.working-directory }}' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' - GEMINI_TRUST_WORKSPACE: true + 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 diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index c5667324721..bd276a38533 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -167,7 +167,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' VERBOSE: 'true' BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max' @@ -213,7 +213,7 @@ jobs: if: "${{runner.os != 'Windows'}}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' @@ -290,7 +290,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e4d59b4a90..2ef8bdb58dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,7 +179,7 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace "@google/gemini-cli" @@ -268,7 +268,7 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false @@ -432,7 +432,7 @@ jobs: env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' NO_COLOR: true - GEMINI_TRUST_WORKSPACE: 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' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index 7644c30fc73..a6a7d3664fc 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -62,7 +62,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}" KEEP_OUTPUT: 'true' RUNS: '${{ github.event.inputs.runs }}' @@ -106,7 +106,7 @@ jobs: if: "runner.os != 'Windows'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' RUNS: '${{ github.event.inputs.runs }}' SANDBOX: 'sandbox:none' @@ -161,7 +161,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml index 11f59010f0c..05d6556f8c6 100644 --- a/.github/workflows/test-build-binary.yml +++ b/.github/workflows/test-build-binary.yml @@ -141,7 +141,7 @@ jobs: if: "github.event_name != 'pull_request'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' - GEMINI_TRUST_WORKSPACE: true + GEMINI_CLI_TRUST_WORKSPACE: true run: | echo "Running integration tests with binary..." if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index 3741d2cee8e..efb99ea397a 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -111,8 +111,8 @@ 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_TRUST_WORKSPACE=true` environment - variable. +- **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. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 033ac940938..27c99cb5dbd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -2114,7 +2114,7 @@ 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_TRUST_WORKSPACE`**: +- **`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). diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 617921b5317..f310e715549 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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'; @@ -462,12 +464,16 @@ export async function parseArguments( yargsInstance.wrap(yargsInstance.terminalWidth()); let result; try { - result = (await yargsInstance.parse()) as Record; + const parsed = await yargsInstance.parse(); + if (!isRecord(parsed)) { + throw new Error('Failed to parse arguments'); + } + result = parsed; if (result['skip-trust']) { - process.env['GEMINI_TRUST_WORKSPACE'] = 'true'; + 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(); @@ -481,10 +487,13 @@ export async function parseArguments( } // Normalize query args: handle both quoted "@path file" and unquoted @path file - 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']) { @@ -598,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; } @@ -1103,7 +1112,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)}`, ); } diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 85f3220a088..8af750db076 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -58,7 +58,7 @@ describe('Trusted Folders', () => { // Reset the internal state resetTrustedFoldersForTesting(); vi.clearAllMocks(); - delete process.env['GEMINI_TRUST_WORKSPACE']; + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; }); afterEach(() => { @@ -433,15 +433,15 @@ describe('Trusted Folders', () => { }); }); - it('should return true when GEMINI_TRUST_WORKSPACE is true', async () => { - process.env['GEMINI_TRUST_WORKSPACE'] = 'true'; + 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_TRUST_WORKSPACE']; + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; } }); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 09217a907b3..20fc80d190a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -280,7 +280,7 @@ describe('gemini.tsx main function', () => { vi.stubEnv('GEMINI_SANDBOX', ''); vi.stubEnv('SANDBOX', ''); vi.stubEnv('SHPOOL_SESSION_NAME', ''); - vi.stubEnv('GEMINI_TRUST_WORKSPACE', 'true'); + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); initialUnhandledRejectionListeners = process.listeners('unhandledRejection'); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 77be712561d..93c166f9c2a 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -181,7 +181,7 @@ describe('gemini.tsx main function cleanup', () => { beforeEach(() => { vi.clearAllMocks(); process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; - vi.stubEnv('GEMINI_TRUST_WORKSPACE', 'true'); + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); }); afterEach(() => { diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index 926608e8590..78627df3e5a 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -96,7 +96,7 @@ const folderTrustCheck: WarningCheck = { if (isHeadlessMode()) { throw new FatalUntrustedWorkspaceError( - 'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.', + '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.', ); } diff --git a/packages/core/src/utils/trust.test.ts b/packages/core/src/utils/trust.test.ts index b340f70e853..f5930972ff0 100644 --- a/packages/core/src/utils/trust.test.ts +++ b/packages/core/src/utils/trust.test.ts @@ -45,7 +45,7 @@ describe('Trust Utility (Core)', () => { vi.mocked(headless.isHeadlessMode).mockReturnValue(false); ideContextStore.clear(); resetTrustedFoldersForTesting(); - delete process.env['GEMINI_TRUST_WORKSPACE']; + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; }); afterEach(() => { diff --git a/packages/core/src/utils/trust.ts b/packages/core/src/utils/trust.ts index e7daa6b3643..bf787469082 100644 --- a/packages/core/src/utils/trust.ts +++ b/packages/core/src/utils/trust.ts @@ -11,7 +11,7 @@ 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 } from './errors.js'; +import { FatalConfigError, getErrorMessage } from './errors.js'; import { coreEvents } from './events.js'; import { ideContextStore } from '../ide/ideContext.js'; @@ -44,7 +44,7 @@ export function isTrustLevel(value: unknown): value is TrustLevel { * IDE context, and local configuration file. */ export function checkPathTrust(options: TrustOptions): TrustResult { - if (process.env['GEMINI_TRUST_WORKSPACE'] === 'true') { + if (process.env['GEMINI_CLI_TRUST_WORKSPACE'] === 'true') { return { isTrusted: true, source: 'env' }; } @@ -207,6 +207,7 @@ export class LoadedTrustedFolders { // 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, }); } @@ -310,7 +311,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders { } } catch (error) { errors.push({ - message: error instanceof Error ? error.message : String(error), + message: getErrorMessage(error), path: userPath, }); } @@ -337,6 +338,7 @@ export function saveTrustedFolders( 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);