fix(trust): Refuse to load extensions from untrusted workspaces (#7342)

This commit is contained in:
Richie Foreman
2025-08-28 16:46:45 -04:00
committed by GitHub
parent 1fc1c2b4e4
commit 2fc857092e
2 changed files with 214 additions and 114 deletions

View File

@@ -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<typeof import('./trustedFolders.js')>();
return {
...actual,
isWorkspaceTrusted: vi.fn(),
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
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, MCPServerConfig>,
): string {
contextFileName = undefined as string | undefined,
mcpServers = {} as Record<string, MCPServerConfig>,
} = {}): 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);

View File

@@ -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<string> {
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 });