From 2fc857092e7cc3d16130f44a361ca8404ba51507 Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Thu, 28 Aug 2025 16:46:45 -0400 Subject: [PATCH] fix(trust): Refuse to load extensions from untrusted workspaces (#7342) --- packages/cli/src/config/extension.test.ts | 311 ++++++++++++++-------- packages/cli/src/config/extension.ts | 17 +- 2 files changed, 214 insertions(+), 114 deletions(-) diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index af73a266b4..e0ec10491d 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -20,14 +20,17 @@ import { performWorkspaceExtensionMigration, uninstallExtension, updateExtension, + type Extension, } from './extension.js'; import { + GEMINI_DIR, type GeminiCLIExtension, type MCPServerConfig, } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; import { SettingScope, loadSettings } from './settings.js'; import { type SimpleGit, simpleGit } from 'simple-git'; +import { isWorkspaceTrusted } from './trustedFolders.js'; vi.mock('simple-git', () => ({ simpleGit: vi.fn(), @@ -41,6 +44,14 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('./trustedFolders.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isWorkspaceTrusted: vi.fn(), + }; +}); + vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { @@ -49,11 +60,12 @@ vi.mock('child_process', async (importOriginal) => { }; }); -const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); +const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); describe('loadExtensions', () => { let tempWorkspaceDir: string; let tempHomeDir: string; + let workspaceExtensionsDir: string; beforeEach(() => { tempWorkspaceDir = fs.mkdtempSync( @@ -63,6 +75,13 @@ describe('loadExtensions', () => { path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + + workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); }); afterEach(() => { @@ -71,24 +90,30 @@ describe('loadExtensions', () => { vi.restoreAllMocks(); }); - it('should include extension path in loaded extension', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + it('ignores extensions in untrusted workspaces', () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); fs.mkdirSync(extensionDir, { recursive: true }); + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: true, + }); + const extensions = loadExtensions(tempWorkspaceDir); + expect(extensions.length).toBe(0); + }); - const config = { + it('should include extension path in loaded extension', () => { + const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); + + createExtension({ + extensionsDir: workspaceExtensionsDir, name: 'test-extension', version: '1.0.0', - }; - fs.writeFileSync( - path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify(config), - ); + }); const extensions = loadExtensions(tempWorkspaceDir); expect(extensions).toHaveLength(1); @@ -97,13 +122,17 @@ describe('loadExtensions', () => { }); it('should load context file path when GEMINI.md is present', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - createExtension(workspaceExtensionsDir, 'ext1', '1.0.0', true); - createExtension(workspaceExtensionsDir, 'ext2', '2.0.0'); + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: true, + }); + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext2', + version: '2.0.0', + }); const extensions = loadExtensions(tempWorkspaceDir); @@ -117,18 +146,13 @@ describe('loadExtensions', () => { }); it('should load context file path from the extension config', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - createExtension( - workspaceExtensionsDir, - 'ext1', - '1.0.0', - false, - 'my-context-file.md', - ); + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: false, + contextFileName: 'my-context-file.md', + }); const extensions = loadExtensions(tempWorkspaceDir); @@ -140,16 +164,18 @@ describe('loadExtensions', () => { }); it('should filter out disabled extensions', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext2', + version: '2.0.0', + }); - createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); - createExtension(workspaceExtensionsDir, 'ext2', '2.0.0'); - - const settingsDir = path.join(tempWorkspaceDir, '.gemini'); + const settingsDir = path.join(tempWorkspaceDir, GEMINI_DIR); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync( path.join(settingsDir, 'settings.json'), @@ -167,24 +193,18 @@ describe('loadExtensions', () => { }); it('should hydrate variables', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - - createExtension( - workspaceExtensionsDir, - 'test-extension', - '1.0.0', - false, - undefined, - { + createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'test-extension', + version: '1.0.0', + addContextFile: false, + contextFileName: undefined, + mcpServers: { 'test-server': { cwd: '${extensionPath}${/}server', }, }, - ); + }); const extensions = loadExtensions(tempWorkspaceDir); expect(extensions).toHaveLength(1); @@ -199,7 +219,7 @@ describe('loadExtensions', () => { }); describe('annotateActiveExtensions', () => { - const extensions = [ + const extensions: Extension[] = [ { path: '/path/to/ext1', config: { name: 'ext1', version: '1.0.0' }, @@ -283,10 +303,11 @@ describe('installExtension', () => { path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); // Clean up before each test fs.rmSync(userExtensionsDir, { recursive: true, force: true }); fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); vi.mocked(execSync).mockClear(); }); @@ -296,11 +317,11 @@ describe('installExtension', () => { }); it('should install an extension from a local path', async () => { - const sourceExtDir = createExtension( - tempHomeDir, - 'my-local-extension', - '1.0.0', - ); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); @@ -317,11 +338,11 @@ describe('installExtension', () => { }); it('should throw an error if the extension already exists', async () => { - const sourceExtDir = createExtension( - tempHomeDir, - 'my-local-extension', - '1.0.0', - ); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); await installExtension({ source: sourceExtDir, type: 'local' }); await expect( installExtension({ source: sourceExtDir, type: 'local' }), @@ -383,7 +404,7 @@ describe('uninstallExtension', () => { path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); // Clean up before each test fs.rmSync(userExtensionsDir, { recursive: true, force: true }); fs.mkdirSync(userExtensionsDir, { recursive: true }); @@ -396,11 +417,11 @@ describe('uninstallExtension', () => { }); it('should uninstall an extension by name', async () => { - const sourceExtDir = createExtension( - userExtensionsDir, - 'my-local-extension', - '1.0.0', - ); + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); await uninstallExtension('my-local-extension'); @@ -408,16 +429,16 @@ describe('uninstallExtension', () => { }); it('should uninstall an extension by name and retain existing extensions', async () => { - const sourceExtDir = createExtension( - userExtensionsDir, - 'my-local-extension', - '1.0.0', - ); - const otherExtDir = createExtension( - userExtensionsDir, - 'other-extension', - '1.0.0', - ); + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + const otherExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'other-extension', + version: '1.0.0', + }); await uninstallExtension('my-local-extension'); @@ -436,6 +457,7 @@ describe('uninstallExtension', () => { describe('performWorkspaceExtensionMigration', () => { let tempWorkspaceDir: string; let tempHomeDir: string; + let workspaceExtensionsDir: string; beforeEach(() => { tempWorkspaceDir = fs.mkdtempSync( @@ -445,6 +467,13 @@ describe('performWorkspaceExtensionMigration', () => { path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + + workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); }); afterEach(() => { @@ -453,15 +482,68 @@ describe('performWorkspaceExtensionMigration', () => { vi.restoreAllMocks(); }); + describe('folder trust', () => { + it('refuses to install extensions from untrusted folders', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const failed = await performWorkspaceExtensionMigration([ + loadExtension(ext1Path)!, + ]); + + expect(failed).toEqual(['ext1']); + }); + + it('does not copy extensions to the user dir', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + + const userExtensionsDir = path.join( + tempHomeDir, + GEMINI_DIR, + 'extensions', + ); + + expect(() => fs.readdirSync(userExtensionsDir)).toThrow(); + }); + + it('does not load any extensions in the workspace config', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + const extensions = loadExtensions(tempWorkspaceDir); + + expect(extensions).toEqual([]); + }); + }); + it('should install the extensions in the user directory', async () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); - const ext2Path = createExtension(workspaceExtensionsDir, 'ext2', '1.0.0'); - const extensionsToMigrate = [ + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + const ext2Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + const extensionsToMigrate: Extension[] = [ loadExtension(ext1Path)!, loadExtension(ext2Path)!, ]; @@ -470,7 +552,7 @@ describe('performWorkspaceExtensionMigration', () => { expect(failed).toEqual([]); - const userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); const userExt1Path = path.join(userExtensionsDir, 'ext1'); const extensions = loadExtensions(tempWorkspaceDir); @@ -485,15 +567,13 @@ describe('performWorkspaceExtensionMigration', () => { }); it('should return the names of failed installations', async () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); - const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); - - const extensions = [ + const extensions: Extension[] = [ loadExtension(ext1Path)!, { path: '/ext/path/1', @@ -507,14 +587,14 @@ describe('performWorkspaceExtensionMigration', () => { }); }); -function createExtension( - extensionsDir: string, - name: string, - version: string, +function createExtension({ + extensionsDir = 'extensions-dir', + name = 'my-extension', + version = '1.0.0', addContextFile = false, - contextFileName?: string, - mcpServers?: Record, -): string { + contextFileName = undefined as string | undefined, + mcpServers = {} as Record, +} = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( @@ -541,10 +621,11 @@ describe('updateExtension', () => { path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); // Clean up before each test fs.rmSync(userExtensionsDir, { recursive: true, force: true }); fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); vi.mocked(execSync).mockClear(); }); @@ -670,7 +751,7 @@ describe('enableExtension', () => { tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); - userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); }); @@ -696,7 +777,11 @@ describe('enableExtension', () => { }; it('should enable an extension at the user scope', () => { - createExtension(userExtensionsDir, 'ext1', '1.0.0'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); disableExtension('ext1', SettingScope.User); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); @@ -708,7 +793,11 @@ describe('enableExtension', () => { }); it('should enable an extension at the workspace scope', () => { - createExtension(userExtensionsDir, 'ext1', '1.0.0'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); disableExtension('ext1', SettingScope.Workspace); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index e19d83b9c1..6816af4eba 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -8,7 +8,7 @@ import type { MCPServerConfig, GeminiCLIExtension, } from '@google/gemini-cli-core'; -import { Storage } from '@google/gemini-cli-core'; +import { GEMINI_DIR, Storage } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -16,8 +16,9 @@ import { simpleGit } from 'simple-git'; import { SettingScope, loadSettings } from '../config/settings.js'; import { getErrorMessage } from '../utils/errors.js'; import { recursivelyHydrateStrings } from './extensions/variables.js'; +import { isWorkspaceTrusted } from './trustedFolders.js'; -export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions'; +export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; @@ -112,7 +113,10 @@ export function loadExtensions(workspaceDir: string): Extension[] { const disabledExtensions = settings.extensions?.disabled ?? []; const allExtensions = [...loadUserExtensions()]; - if (!settings.experimental?.extensionManagement) { + if ( + (isWorkspaceTrusted(settings) ?? true) && + !settings.experimental?.extensionManagement + ) { allExtensions.push(...getWorkspaceExtensions(workspaceDir)); } @@ -323,6 +327,13 @@ export async function installExtension( installMetadata: ExtensionInstallMetadata, cwd: string = process.cwd(), ): Promise { + const settings = loadSettings(cwd).merged; + if (!isWorkspaceTrusted(settings)) { + throw new Error( + `Could not install extension from untrusted folder at ${installMetadata.source}`, + ); + } + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); await fs.promises.mkdir(extensionsDir, { recursive: true });