From adddafe6d07eea74561bd71e88aef0ce2a546b4a Mon Sep 17 00:00:00 2001 From: kevinjwang1 Date: Fri, 31 Oct 2025 19:17:01 +0000 Subject: [PATCH] Handle untrusted folders on extension install and link (#12322) Co-authored-by: Jacob MacDonald --- packages/cli/src/config/extension-manager.ts | 22 +++-- packages/cli/src/config/extension.test.ts | 87 +++++++++++++++++++- packages/cli/src/config/trustedFolders.ts | 7 +- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 9980474e73..55e66c811d 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -12,7 +12,11 @@ import { ExtensionEnablementManager } from './extensions/extensionEnablement.js' import { type Settings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; -import { isWorkspaceTrusted } from './trustedFolders.js'; +import { + isWorkspaceTrusted, + loadTrustedFolders, + TrustLevel, +} from './trustedFolders.js'; import { cloneFromGit, downloadFromGitHubRelease, @@ -136,11 +140,19 @@ export class ExtensionManager implements ExtensionLoader { let extension: GeminiCLIExtension | null; try { if (!isWorkspaceTrusted(this.settings).isTrusted) { - throw new Error( - `Could not install extension from untrusted folder at ${installMetadata.source}`, - ); + if ( + await this.requestConsent( + `The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, + ) + ) { + const trustedFolders = loadTrustedFolders(); + trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER); + } else { + throw new Error( + `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, + ); + } } - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); await fs.promises.mkdir(extensionsDir, { recursive: true }); diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 21df5f26de..8a4af9ac31 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -16,7 +16,10 @@ import { KeychainTokenStorage, } from '@google/gemini-cli-core'; import { loadSettings, SettingScope } from './settings.js'; -import { isWorkspaceTrusted } from './trustedFolders.js'; +import { + isWorkspaceTrusted, + resetTrustedFoldersForTesting, +} from './trustedFolders.js'; import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { join } from 'node:path'; @@ -182,6 +185,7 @@ describe('extension tests', () => { requestSetting: mockPromptForSettings, settings: loadSettings(tempWorkspaceDir).merged, }); + resetTrustedFoldersForTesting(); }); afterEach(() => { @@ -872,6 +876,87 @@ describe('extension tests', () => { fs.rmSync(targetExtDir, { recursive: true, force: true }); }); + it('should prompt for trust if workspace is not trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(mockRequestConsent).toHaveBeenCalledWith( + `The current workspace at "${tempWorkspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, + ); + }); + + it('should not install if user denies trust', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + mockRequestConsent.mockImplementation(async (message) => { + if ( + message.includes( + 'is not trusted. Do you want to trust this workspace to install extensions?', + ) + ) { + return false; + } + return true; + }); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow( + `Could not install extension because the current workspace at ${tempWorkspaceDir} is not trusted.`, + ); + }); + + it('should add the workspace to trusted folders if user consents', async () => { + const trustedFoldersPath = path.join( + tempHomeDir, + '.gemini', + 'trustedFolders.json', + ); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + expect(fs.existsSync(trustedFoldersPath)).toBe(true); + const trustedFolders = JSON.parse( + fs.readFileSync(trustedFoldersPath, 'utf-8'), + ); + expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER'); + }); + describe.each([true, false])( 'with previous extension config: %s', (isUpdate: boolean) => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 2b592b5cb8..6602ec85cb 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -18,13 +18,16 @@ import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; -export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR); + +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(USER_SETTINGS_DIR, TRUSTED_FOLDERS_FILENAME); + return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME); } export enum TrustLevel {