From cec45a1ebccd8f7b84e0a4b384f9469f1af5457d Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Mon, 23 Feb 2026 14:08:56 -0800 Subject: [PATCH] fix(cli): skip workspace policy loading when in home directory (#20054) --- packages/cli/src/config/policy.test.ts | 44 ++++++++++++++++++++++ packages/cli/src/config/policy.ts | 12 ++++-- packages/cli/src/config/settings.test.ts | 48 ++++++++++++++++++++---- packages/cli/src/config/settings.ts | 26 +++---------- packages/core/src/config/storage.ts | 12 ++++++ 5 files changed, 111 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index a0e687388d..1a773d56a7 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => { expect.stringContaining('Automatically accepting and loading'), ); }); + + it('should not return workspace policies if cwd is the home directory', async () => { + const policiesDir = path.join(tempDir, '.gemini', 'policies'); + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // Run from HOME directory (tempDir is mocked as HOME in beforeEach) + const result = await resolveWorkspacePolicyState({ + cwd: tempDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should not return workspace policies if cwd is a symlink to the home directory', async () => { + const policiesDir = path.join(tempDir, '.gemini', 'policies'); + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // Create a symlink to the home directory + const symlinkDir = path.join( + os.tmpdir(), + `gemini-cli-symlink-${Date.now()}`, + ); + fs.symlinkSync(tempDir, symlinkDir, 'dir'); + + try { + // Run from symlink to HOME directory + const result = await resolveWorkspacePolicyState({ + cwd: symlinkDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + } finally { + // Clean up symlink + fs.unlinkSync(symlinkDir); + } + }); }); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index ef6164efb7..3b85d0b4b6 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: { | undefined; if (trustedFolder) { - const potentialWorkspacePoliciesDir = new Storage( - cwd, - ).getWorkspacePoliciesDir(); + const storage = new Storage(cwd); + + // If we are in the home directory (or rather, our target Gemini dir is the global one), + // don't treat it as a workspace to avoid loading global policies twice. + if (storage.isWorkspaceHomeDir()) { + return { workspacePoliciesDir: undefined }; + } + + const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir(); const integrityManager = new PolicyIntegrityManager(); const integrityResult = await integrityManager.checkIntegrity( 'workspace', diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 7b341b3ee0..6b2f18bb58 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -79,6 +79,7 @@ import { import { FatalConfigError, GEMINI_DIR, + Storage, type MCPServerConfig, } from '@google/gemini-cli-core'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; @@ -126,6 +127,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); const os = await import('node:os'); + const pathMod = await import('node:path'); + const fsMod = await import('node:fs'); + + // Helper to resolve paths using the test's mocked environment + const testResolve = (p: string | undefined) => { + if (!p) return ''; + try { + // Use the mocked fs.realpathSync if available, otherwise fallback + return fsMod.realpathSync(pathMod.resolve(p)); + } catch { + return pathMod.resolve(p); + } + }; + + // Create a smarter mock for isWorkspaceHomeDir + vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation( + function (this: Storage) { + const target = testResolve(pathMod.dirname(this.getGeminiDir())); + // Pick up the mocked home directory specifically from the 'os' mock + const home = testResolve(os.homedir()); + return actual.normalizePath(target) === actual.normalizePath(home); + }, + ); + return { ...actual, coreEvents: mockCoreEvents, @@ -1491,20 +1516,29 @@ describe('Settings Loading and Merging', () => { return pStr; }); + // Force the storage check to return true for this specific test + const isWorkspaceHomeDirSpy = vi + .spyOn(Storage.prototype, 'isWorkspaceHomeDir') + .mockReturnValue(true); + (mockFsExistsSync as Mock).mockImplementation( (p: string) => // Only return true for workspace settings path to see if it gets loaded p === mockWorkspaceSettingsPath, ); - const settings = loadSettings(mockSymlinkDir); + try { + const settings = loadSettings(mockSymlinkDir); - // Verify that even though the file exists, it was NOT loaded because realpath matched home - expect(fs.readFileSync).not.toHaveBeenCalledWith( - mockWorkspaceSettingsPath, - 'utf-8', - ); - expect(settings.workspace.settings).toEqual({}); + // Verify that even though the file exists, it was NOT loaded because realpath matched home + expect(fs.readFileSync).not.toHaveBeenCalledWith( + mockWorkspaceSettingsPath, + 'utf-8', + ); + expect(settings.workspace.settings).toEqual({}); + } finally { + isWorkspaceHomeDirSpy.mockRestore(); + } }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 2f6f2f7450..c3f7c447eb 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -637,24 +637,8 @@ export function loadSettings( const systemSettingsPath = getSystemSettingsPath(); const systemDefaultsPath = getSystemDefaultsPath(); - // Resolve paths to their canonical representation to handle symlinks - const resolvedWorkspaceDir = path.resolve(workspaceDir); - const resolvedHomeDir = path.resolve(homedir()); - - let realWorkspaceDir = resolvedWorkspaceDir; - try { - // fs.realpathSync gets the "true" path, resolving any symlinks - realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); - } catch (_e) { - // This is okay. The path might not exist yet, and that's a valid state. - } - - // We expect homedir to always exist and be resolvable. - const realHomeDir = fs.realpathSync(resolvedHomeDir); - - const workspaceSettingsPath = new Storage( - workspaceDir, - ).getWorkspaceSettingsPath(); + const storage = new Storage(workspaceDir); + const workspaceSettingsPath = storage.getWorkspaceSettingsPath(); const load = (filePath: string): { settings: Settings; rawJson?: string } => { try { @@ -712,7 +696,7 @@ export function loadSettings( settings: {} as Settings, rawJson: undefined, }; - if (realWorkspaceDir !== realHomeDir) { + if (!storage.isWorkspaceHomeDir()) { workspaceResult = load(workspaceSettingsPath); } @@ -800,11 +784,11 @@ export function loadSettings( readOnly: false, }, { - path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath, + path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath, settings: workspaceSettings, originalSettings: workspaceOriginalSettings, rawJson: workspaceResult.rawJson, - readOnly: realWorkspaceDir === realHomeDir, + readOnly: storage.isWorkspaceHomeDir(), }, isTrusted, settingsErrors, diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 3099f39d1e..f66d60ef8b 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -14,6 +14,7 @@ import { GOOGLE_ACCOUNTS_FILENAME, isSubpath, resolveToRealPath, + normalizePath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; @@ -142,6 +143,17 @@ export class Storage { return path.join(this.targetDir, GEMINI_DIR); } + /** + * Checks if the current workspace storage location is the same as the global/user storage location. + * This handles symlinks and platform-specific path normalization. + */ + isWorkspaceHomeDir(): boolean { + return ( + normalizePath(resolveToRealPath(this.targetDir)) === + normalizePath(resolveToRealPath(homedir())) + ); + } + getAgentsDir(): string { return path.join(this.targetDir, AGENTS_DIR_NAME); }