feat(extensions): enforce folder trust for local extension install (#19703)

This commit is contained in:
Gal Zahavi
2026-02-24 11:58:44 -08:00
committed by GitHub
parent 4dd940f8ce
commit 6510347d5b
9 changed files with 592 additions and 116 deletions
@@ -16,14 +16,20 @@ import {
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import * as core from '@google/gemini-cli-core';
import type { inferInstallMetadata } from '../../config/extension-manager.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import type {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import type {
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
@@ -31,28 +37,54 @@ const mockInstallOrUpdateExtension: Mock<
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockPromptForConsentNonInteractive: Mock<
typeof promptForConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
INSTALL_WARNING_MESSAGE: 'warning',
}));
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: mockIsWorkspaceTrusted,
loadTrustedFolders: mockLoadTrustedFolders,
TrustLevel: {
TRUST_FOLDER: 'TRUST_FOLDER',
},
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
inferInstallMetadata: mockInferInstallMetadata,
FolderTrustDiscoveryService: {
discover: mockDiscover,
},
};
});
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../config/extension-manager.js')
>()),
inferInstallMetadata: mockInferInstallMetadata,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -83,12 +115,31 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi.spyOn(debugLogger, 'log');
debugErrorSpy = vi.spyOn(debugLogger, 'error');
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
[],
);
vi.spyOn(
ExtensionManager.prototype,
'installOrUpdateExtension',
).mockImplementation(mockInstallOrUpdateExtension);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
mockDiscover.mockResolvedValue({
commands: [],
mcps: [],
hooks: [],
skills: [],
settings: [],
securityWarnings: [],
discoveryErrors: [],
});
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
@@ -114,12 +165,29 @@ describe('handleInstall', () => {
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
function createMockExtension(
overrides: Partial<core.GeminiCLIExtension> = {},
): core.GeminiCLIExtension {
return {
name: 'mock-extension',
version: '1.0.0',
isActive: true,
path: '/mock/path',
contextFiles: [],
id: 'mock-id',
...overrides,
};
}
it('should install an extension from a http source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'http-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'http-extension',
}),
);
await handleInstall({
source: 'http://google.com',
@@ -131,9 +199,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a https source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'https-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'https-extension',
}),
);
await handleInstall({
source: 'https://google.com',
@@ -145,9 +215,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a git source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'git-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'git-extension',
}),
);
await handleInstall({
source: 'git@some-url',
@@ -171,9 +243,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a sso source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'sso-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'sso-extension',
}),
);
await handleInstall({
source: 'sso://google.com',
@@ -185,12 +259,14 @@ describe('handleInstall', () => {
});
it('should install an extension from a local path', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'local-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
await handleInstall({
source: '/some/path',
source: path.join('/', 'some', 'path'),
});
expect(debugLogSpy).toHaveBeenCalledWith(
@@ -208,4 +284,144 @@ describe('handleInstall', () => {
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should proceed if local path is already trusted', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
await handleInstall({
source: path.join('/', 'some', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should prompt and proceed if user accepts trust', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockPromptForConsentNonInteractive.mockResolvedValue(true);
const mockSetValue = vi.fn();
mockLoadTrustedFolders.mockReturnValue({
setValue: mockSetValue,
user: { path: '', config: {} },
errors: [],
rules: [],
isPathTrusted: vi.fn(),
});
await handleInstall({
source: path.join('/', 'untrusted', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
expect(mockSetValue).toHaveBeenCalledWith(
expect.stringContaining(path.join('untrusted', 'path')),
'TRUST_FOLDER',
);
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should prompt and abort if user denies trust', async () => {
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockPromptForConsentNonInteractive.mockResolvedValue(false);
await handleInstall({
source: path.join('/', 'evil', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Installation aborted: Folder'),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should include discovery results in trust prompt', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockDiscover.mockResolvedValue({
commands: ['custom-cmd'],
mcps: [],
hooks: [],
skills: ['cool-skill'],
settings: [],
securityWarnings: ['Security risk!'],
discoveryErrors: ['Read error'],
});
mockPromptForConsentNonInteractive.mockResolvedValue(true);
mockLoadTrustedFolders.mockReturnValue({
setValue: vi.fn(),
user: { path: '', config: {} },
errors: [],
rules: [],
isPathTrusted: vi.fn(),
});
await handleInstall({
source: '/untrusted/path',
});
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('This folder contains:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('custom-cmd'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('cool-skill'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security Warnings:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security risk!'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Discovery Errors:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Read error'),
false,
);
});
});
// Implementation completed.
+102 -3
View File
@@ -5,10 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import chalk from 'chalk';
import {
debugLogger,
FolderTrustDiscoveryService,
getRealPath,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import {
@@ -16,6 +22,11 @@ import {
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import {
isWorkspaceTrusted,
loadTrustedFolders,
TrustLevel,
} from '../../config/trustedFolders.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -36,6 +47,95 @@ export async function handleInstall(args: InstallArgs) {
allowPreRelease: args.allowPreRelease,
});
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
const resolvedPath = getRealPath(source);
installMetadata.source = resolvedPath;
const trustResult = isWorkspaceTrusted(settings, resolvedPath);
if (trustResult.isTrusted !== true) {
const discoveryResults =
await FolderTrustDiscoveryService.discover(resolvedPath);
const hasDiscovery =
discoveryResults.commands.length > 0 ||
discoveryResults.mcps.length > 0 ||
discoveryResults.hooks.length > 0 ||
discoveryResults.skills.length > 0 ||
discoveryResults.settings.length > 0;
const promptLines = [
'',
chalk.bold('Do you trust the files in this folder?'),
'',
`The extension source at "${resolvedPath}" is not trusted.`,
'',
'Trusting a folder allows Gemini CLI to load its local configurations,',
'including custom commands, hooks, MCP servers, agent skills, and',
'settings. These configurations could execute code on your behalf or',
'change the behavior of the CLI.',
'',
];
if (discoveryResults.discoveryErrors.length > 0) {
promptLines.push(chalk.red('❌ Discovery Errors:'));
for (const error of discoveryResults.discoveryErrors) {
promptLines.push(chalk.red(`${error}`));
}
promptLines.push('');
}
if (discoveryResults.securityWarnings.length > 0) {
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
for (const warning of discoveryResults.securityWarnings) {
promptLines.push(chalk.yellow(`${warning}`));
}
promptLines.push('');
}
if (hasDiscovery) {
promptLines.push(chalk.bold('This folder contains:'));
const groups = [
{ label: 'Commands', items: discoveryResults.commands },
{ label: 'MCP Servers', items: discoveryResults.mcps },
{ label: 'Hooks', items: discoveryResults.hooks },
{ label: 'Skills', items: discoveryResults.skills },
{ label: 'Setting overrides', items: discoveryResults.settings },
].filter((g) => g.items.length > 0);
for (const group of groups) {
promptLines.push(
`${chalk.bold(group.label)} (${group.items.length}):`,
);
for (const item of group.items) {
promptLines.push(` - ${item}`);
}
}
promptLines.push('');
}
promptLines.push(
chalk.yellow(
'Do you want to trust this folder and continue with the installation? [y/N]: ',
),
);
const confirmed = await promptForConsentNonInteractive(
promptLines.join('\n'),
false,
);
if (confirmed) {
const trustedFolders = loadTrustedFolders();
await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
} else {
throw new Error(
`Installation aborted: Folder "${resolvedPath}" is not trusted.`,
);
}
}
}
const requestConsent = args.consent
? () => Promise.resolve(true)
: requestConsentNonInteractive;
@@ -44,12 +144,11 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: promptForSetting,
settings: loadSettings(workspaceDir).merged,
settings,
});
await extensionManager.loadExtensions();
const extension =