Check folder trust before allowing add directory (#12652)

This commit is contained in:
shrutip90
2025-11-14 19:06:30 -08:00
committed by GitHub
parent d03496b710
commit 9786c4dcff
18 changed files with 1206 additions and 66 deletions

View File

@@ -112,6 +112,7 @@ vi.mock('@google/gemini-cli-core', async () => {
return Promise.resolve({
memoryContent: extensionPaths.join(',') || '',
fileCount: extensionPaths?.length || 0,
filePaths: extensionPaths,
});
},
),
@@ -1547,11 +1548,13 @@ describe('loadCliConfig with includeDirectories', () => {
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
const directories = config.getWorkspaceContext().getDirectories();
expect(directories).toEqual([mockCwd]);
expect(config.getPendingIncludeDirectories()).toEqual(
expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)),
);
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
expect(config.getPendingIncludeDirectories()).toHaveLength(
expected.length - 1,
);
});
});

View File

@@ -424,9 +424,7 @@ export async function loadCliConfig(
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
cwd,
settings.context?.loadMemoryFromIncludeDirectories
? includeDirectories
: [],
[],
debugMode,
fileService,
extensionManager,

View File

@@ -105,6 +105,8 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
@@ -161,6 +163,9 @@ export const AppContainer = (props: AppContainerProps) => {
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
null,
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
@@ -169,7 +174,7 @@ export const AppContainer = (props: AppContainerProps) => {
const [historyRemountKey, setHistoryRemountKey] = useState(0);
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
config.isTrustedFolder(),
isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
@@ -591,6 +596,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
setCustomDialog,
);
const performMemoryRefresh = useCallback(async () => {
@@ -908,6 +914,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
} = useIdeTrustListener();
const isInitialMount = useRef(true);
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
@@ -1263,6 +1271,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
!!customDialog ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
@@ -1382,6 +1391,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
activePtyId,
embeddedShellFocused,
showDebugProfiler,
customDialog,
copyModeEnabled,
warningMessage,
}),
@@ -1467,6 +1477,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
historyManager,
embeddedShellFocused,
showDebugProfiler,
customDialog,
apiKeyDefaultValue,
authState,
copyModeEnabled,

View File

@@ -4,14 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { Mock } from 'vitest';
import { directoryCommand } from './directoryCommand.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
import type { CommandContext } from './types.js';
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as trustedFolders from '../../config/trustedFolders.js';
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
describe('directoryCommand', () => {
let mockContext: CommandContext;
@@ -83,6 +87,18 @@ describe('directoryCommand', () => {
});
describe('add', () => {
it('should show an error in a restrictive sandbox', async () => {
if (!addCommand?.action) throw new Error('No action');
vi.mocked(mockConfig.isRestrictiveSandbox).mockReturnValue(true);
const result = await addCommand.action(mockContext, '/some/path');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
});
});
it('should show an error if no path is provided', () => {
if (!addCommand?.action) throw new Error('No action');
addCommand.action(mockContext, '');
@@ -142,6 +158,32 @@ describe('directoryCommand', () => {
);
});
it('should add directory directly when folder trust is disabled', async () => {
if (!addCommand?.action) throw new Error('No action');
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
const newPath = path.normalize('/home/user/new-project');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
});
it('should show an info message for an already added directory', async () => {
const existingPath = path.normalize('/home/user/project1');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, existingPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `The following directories are already in the workspace:\n- ${existingPath}`,
}),
expect.any(Number),
);
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith(
existingPath,
);
});
it('should handle a mix of successful and failed additions', async () => {
const validPath = path.normalize('/home/user/valid-project');
const invalidPath = path.normalize('/home/user/invalid-project');
@@ -174,6 +216,80 @@ describe('directoryCommand', () => {
);
});
});
describe('add with folder trust enabled', () => {
let mockIsPathTrusted: Mock;
beforeEach(() => {
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: true,
source: 'file',
});
mockIsPathTrusted = vi.fn();
const mockLoadedFolders = {
isPathTrusted: mockIsPathTrusted,
} as unknown as LoadedTrustedFolders;
vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue(
mockLoadedFolders,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should add a trusted directory', async () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(true);
const newPath = path.normalize('/home/user/trusted-project');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
});
it('should show an error for an untrusted directory', async () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(false);
const newPath = path.normalize('/home/user/untrusted-project');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('explicitly untrusted'),
}),
expect.any(Number),
);
});
it('should return a custom dialog for a directory with undefined trust', async () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(undefined);
const newPath = path.normalize('/home/user/undefined-trust-project');
const result = await addCommand.action(mockContext, newPath);
expect(result).toEqual(
expect.objectContaining({
type: 'custom_dialog',
component: expect.objectContaining({
type: expect.any(Function), // React component for MultiFolderTrustDialog
}),
}),
);
if (!result) {
throw new Error('Command did not return a result');
}
const component = (result as OpenCustomDialogActionReturn)
.component as React.ReactElement<MultiFolderTrustDialogProps>;
expect(component.props.folders.includes(newPath)).toBeTruthy();
});
});
it('should correctly expand a Windows-style home directory path', () => {
const windowsPath = '%userprofile%\\Documents';
const expectedPath = path.win32.join(os.homedir(), 'Documents');

View File

@@ -4,11 +4,69 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
isFolderTrustEnabled,
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItem } from '../types.js';
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
import { expandHomeDir } from '../utils/directoryUtils.js';
import type { Config } from '@google/gemini-cli-core';
async function finishAddingDirectories(
config: Config,
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number,
added: string[],
errors: string[],
) {
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
}
if (errors.length > 0) {
addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now());
}
}
export const directoryCommand: SlashCommand = {
name: 'directory',
@@ -24,7 +82,7 @@ export const directoryCommand: SlashCommand = {
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },
services: { config },
services: { config, settings },
} = context;
const [...rest] = args.split(' ');
@@ -39,7 +97,14 @@ export const directoryCommand: SlashCommand = {
return;
}
const workspaceContext = config.getWorkspaceContext();
if (config.isRestrictiveSandbox()) {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
};
}
const pathsToAdd = rest
.join(' ')
@@ -56,63 +121,109 @@ export const directoryCommand: SlashCommand = {
return;
}
if (config.isRestrictiveSandbox()) {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
};
}
const added: string[] = [];
const errors: string[] = [];
const alreadyAdded: string[] = [];
const workspaceContext = config.getWorkspaceContext();
const currentWorkspaceDirs = workspaceContext.getDirectories();
const pathsToProcess: string[] = [];
for (const pathToAdd of pathsToAdd) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
const expandedPath = expandHomeDir(pathToAdd.trim());
if (currentWorkspaceDirs.includes(expandedPath)) {
alreadyAdded.push(pathToAdd.trim());
} else {
pathsToProcess.push(pathToAdd.trim());
}
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
}
if (alreadyAdded.length > 0) {
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
text: `The following directories are already in the workspace:\n- ${alreadyAdded.join(
'\n- ',
)}`,
},
Date.now(),
);
}
if (errors.length > 0) {
addItem(
{ type: MessageType.ERROR, text: errors.join('\n') },
Date.now(),
);
if (pathsToProcess.length === 0) {
return;
}
if (
isFolderTrustEnabled(settings.merged) &&
isWorkspaceTrusted(settings.merged).isTrusted
) {
const trustedFolders = loadTrustedFolders();
const untrustedDirs: string[] = [];
const undefinedTrustDirs: string[] = [];
const trustedDirs: string[] = [];
for (const pathToAdd of pathsToProcess) {
const expandedPath = expandHomeDir(pathToAdd.trim());
const isTrusted = trustedFolders.isPathTrusted(expandedPath);
if (isTrusted === false) {
untrustedDirs.push(pathToAdd.trim());
} else if (isTrusted === undefined) {
undefinedTrustDirs.push(pathToAdd.trim());
} else {
trustedDirs.push(pathToAdd.trim());
}
}
if (untrustedDirs.length > 0) {
errors.push(
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join(
'\n- ',
)}\nPlease use the permissions command to modify their trust level.`,
);
}
for (const pathToAdd of trustedDirs) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
added.push(pathToAdd);
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
}
}
if (undefinedTrustDirs.length > 0) {
return {
type: 'custom_dialog',
component: (
<MultiFolderTrustDialog
folders={undefinedTrustDirs}
onComplete={context.ui.removeComponent}
trustedDirs={added}
errors={errors}
finishAddingDirectories={finishAddingDirectories}
config={config}
addItem={addItem}
/>
),
};
}
} else {
for (const pathToAdd of pathsToProcess) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(
`Error adding '${pathToAdd.trim()}': ${error.message}`,
);
}
}
}
await finishAddingDirectories(config, addItem, added, errors);
return;
},
},

View File

@@ -72,6 +72,7 @@ export interface CommandContext {
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
};
// Session-specific data
session: {
@@ -169,6 +170,11 @@ export interface ConfirmActionReturn {
};
}
export interface OpenCustomDialogActionReturn {
type: 'custom_dialog';
component: ReactNode;
}
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
@@ -177,7 +183,8 @@ export type SlashCommandActionReturn =
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn
| ConfirmActionReturn;
| ConfirmActionReturn
| OpenCustomDialogActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',

View File

@@ -0,0 +1,259 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { act } from 'react-dom/test-utils';
import {
MultiFolderTrustDialog,
MultiFolderTrustChoice,
type MultiFolderTrustDialogProps,
} from './MultiFolderTrustDialog.js';
import { vi } from 'vitest';
import {
TrustLevel,
type LoadedTrustedFolders,
} from '../../config/trustedFolders.js';
import * as trustedFolders from '../../config/trustedFolders.js';
import * as directoryUtils from '../utils/directoryUtils.js';
import type { Config } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
// Mocks
vi.mock('../hooks/useKeypress.js');
vi.mock('../../config/trustedFolders.js');
vi.mock('../utils/directoryUtils.js');
vi.mock('./shared/RadioButtonSelect.js');
const mockedUseKeypress = vi.mocked(useKeypress);
const mockedRadioButtonSelect = vi.mocked(RadioButtonSelect);
const mockOnComplete = vi.fn();
const mockFinishAddingDirectories = vi.fn();
const mockAddItem = vi.fn();
const mockAddDirectory = vi.fn();
const mockSetValue = vi.fn();
const mockConfig = {
getWorkspaceContext: () => ({
addDirectory: mockAddDirectory,
}),
} as unknown as Config;
const mockTrustedFolders = {
setValue: mockSetValue,
} as unknown as LoadedTrustedFolders;
const defaultProps: MultiFolderTrustDialogProps = {
folders: [],
onComplete: mockOnComplete,
trustedDirs: [],
errors: [],
finishAddingDirectories: mockFinishAddingDirectories,
config: mockConfig,
addItem: mockAddItem,
};
describe('MultiFolderTrustDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(trustedFolders.loadTrustedFolders).mockReturnValue(
mockTrustedFolders,
);
vi.mocked(directoryUtils.expandHomeDir).mockImplementation((path) => path);
mockedRadioButtonSelect.mockImplementation((props) => (
<div data-testid="RadioButtonSelect" {...props} />
));
});
it('renders the dialog with the list of folders', () => {
const folders = ['/path/to/folder1', '/path/to/folder2'];
const { lastFrame } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
expect(lastFrame()).toContain(
'Do you trust the following folders being added to this workspace?',
);
expect(lastFrame()).toContain('- /path/to/folder1');
expect(lastFrame()).toContain('- /path/to/folder2');
});
it('calls onComplete and finishAddingDirectories with an error on escape', async () => {
const folders = ['/path/to/folder1'];
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
const keypressCallback = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
await keypressCallback({
name: 'escape',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '',
insertable: false,
});
});
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
mockAddItem,
[],
[
'Operation cancelled. The following directories were not added:\n- /path/to/folder1',
],
);
expect(mockOnComplete).toHaveBeenCalled();
});
it('calls finishAddingDirectories with an error and does not add directories when "No" is chosen', async () => {
const folders = ['/path/to/folder1'];
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect(MultiFolderTrustChoice.NO);
});
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
mockAddItem,
[],
[
'The following directories were not added because they were not trusted:\n- /path/to/folder1',
],
);
expect(mockOnComplete).toHaveBeenCalled();
expect(mockAddDirectory).not.toHaveBeenCalled();
expect(mockSetValue).not.toHaveBeenCalled();
});
it('adds directories to workspace context when "Yes" is chosen', async () => {
const folders = ['/path/to/folder1', '/path/to/folder2'];
render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
trustedDirs={['/already/trusted']}
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect(MultiFolderTrustChoice.YES);
});
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/folder1');
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/folder2');
expect(mockSetValue).not.toHaveBeenCalled();
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
mockAddItem,
['/already/trusted', '/path/to/folder1', '/path/to/folder2'],
[],
);
expect(mockOnComplete).toHaveBeenCalled();
});
it('adds directories to workspace context and remembers them as trusted when "Yes, and remember" is chosen', async () => {
const folders = ['/path/to/folder1'];
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect(MultiFolderTrustChoice.YES_AND_REMEMBER);
});
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/folder1');
expect(mockSetValue).toHaveBeenCalledWith(
'/path/to/folder1',
TrustLevel.TRUST_FOLDER,
);
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
mockAddItem,
['/path/to/folder1'],
[],
);
expect(mockOnComplete).toHaveBeenCalled();
});
it('shows submitting message after a choice is made', async () => {
const folders = ['/path/to/folder1'];
const { lastFrame } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect(MultiFolderTrustChoice.NO);
});
expect(lastFrame()).toContain('Applying trust settings...');
});
it('shows an error message and completes when config is missing', async () => {
const folders = ['/path/to/folder1'];
render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
config={null as unknown as Config}
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect(MultiFolderTrustChoice.YES);
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
expect.any(Number),
);
expect(mockOnComplete).toHaveBeenCalled();
expect(mockFinishAddingDirectories).not.toHaveBeenCalled();
});
it('collects and reports errors when some directories fail to be added', async () => {
vi.mocked(directoryUtils.expandHomeDir).mockImplementation((path) => {
if (path === '/path/to/error') {
throw new Error('Test error');
}
return path;
});
const folders = ['/path/to/good', '/path/to/error'];
render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
errors={['initial error']}
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect(MultiFolderTrustChoice.YES);
});
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/good');
expect(mockAddDirectory).not.toHaveBeenCalledWith('/path/to/error');
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
mockAddItem,
['/path/to/good'],
['initial error', "Error adding '/path/to/error': Test error"],
);
expect(mockOnComplete).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { useState } from 'react';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import { MessageType, type HistoryItem } from '../types.js';
import type { Config } from '@google/gemini-cli-core';
export enum MultiFolderTrustChoice {
YES,
YES_AND_REMEMBER,
NO,
}
export interface MultiFolderTrustDialogProps {
folders: string[];
onComplete: () => void;
trustedDirs: string[];
errors: string[];
finishAddingDirectories: (
config: Config,
addItem: (
itemData: Omit<HistoryItem, 'id'>,
baseTimestamp: number,
) => number,
added: string[],
errors: string[],
) => Promise<void>;
config: Config;
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number;
}
export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
folders,
onComplete,
trustedDirs,
errors: initialErrors,
finishAddingDirectories,
config,
addItem,
}) => {
const [submitted, setSubmitted] = useState(false);
const handleCancel = async () => {
setSubmitted(true);
const errors = [...initialErrors];
errors.push(
`Operation cancelled. The following directories were not added:\n- ${folders.join(
'\n- ',
)}`,
);
await finishAddingDirectories(config, addItem, trustedDirs, errors);
onComplete();
};
useKeypress(
(key) => {
if (key.name === 'escape') {
handleCancel();
}
},
{ isActive: !submitted },
);
const options: Array<RadioSelectItem<MultiFolderTrustChoice>> = [
{
label: 'Yes',
value: MultiFolderTrustChoice.YES,
key: 'yes',
},
{
label: 'Yes, and remember the directories as trusted',
value: MultiFolderTrustChoice.YES_AND_REMEMBER,
key: 'yes-and-remember',
},
{
label: 'No',
value: MultiFolderTrustChoice.NO,
key: 'no',
},
];
const handleSelect = async (choice: MultiFolderTrustChoice) => {
setSubmitted(true);
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
onComplete();
return;
}
const workspaceContext = config.getWorkspaceContext();
const trustedFolders = loadTrustedFolders();
const errors = [...initialErrors];
const added = [...trustedDirs];
if (choice === MultiFolderTrustChoice.NO) {
errors.push(
`The following directories were not added because they were not trusted:\n- ${folders.join(
'\n- ',
)}`,
);
} else {
for (const dir of folders) {
try {
const expandedPath = expandHomeDir(dir);
if (choice === MultiFolderTrustChoice.YES_AND_REMEMBER) {
trustedFolders.setValue(expandedPath, TrustLevel.TRUST_FOLDER);
}
workspaceContext.addDirectory(expandedPath);
added.push(dir);
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${dir}': ${error.message}`);
}
}
}
await finishAddingDirectories(config, addItem, added, errors);
onComplete();
};
return (
<Box flexDirection="column">
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Do you trust the following folders being added to this workspace?
</Text>
<Text color={theme.text.secondary}>
{folders.map((f) => `- ${f}`).join('\n')}
</Text>
<Text color={theme.text.primary}>
Trusting a folder allows Gemini to read and perform auto-edits when
in auto-approval mode. This is a security feature to prevent
accidental execution in untrusted directories.
</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={!submitted}
/>
</Box>
{submitted && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.text.primary}>Applying trust settings...</Text>
</Box>
)}
</Box>
);
};

View File

@@ -126,6 +126,7 @@ export interface UIState {
showFullTodos: boolean;
copyModeEnabled: boolean;
warningMessage: string | null;
customDialog: React.ReactNode | null;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
vi.fn(), // setCustomDialog
),
);
result = hook.result;

View File

@@ -77,6 +77,7 @@ export const useSlashCommandProcessor = (
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
setCustomDialog: (dialog: React.ReactNode | null) => void,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
@@ -215,6 +216,7 @@ export const useSlashCommandProcessor = (
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
removeComponent: () => setCustomDialog(null),
},
session: {
stats: session.stats,
@@ -239,6 +241,7 @@ export const useSlashCommandProcessor = (
sessionShellAllowlist,
reloadCommands,
extensionsUpdateState,
setCustomDialog,
],
);
@@ -505,6 +508,10 @@ export const useSlashCommandProcessor = (
true,
);
}
case 'custom_dialog': {
setCustomDialog(result.component);
return { type: 'handled' };
}
default: {
const unhandled: never = result;
throw new Error(
@@ -578,6 +585,7 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
setCustomDialog,
],
);

View File

@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { Mock } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useIncludeDirsTrust } from './useIncludeDirsTrust.js';
import * as trustedFolders from '../../config/trustedFolders.js';
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
vi.mock('../utils/directoryUtils.js', () => ({
expandHomeDir: (p: string) => p, // Simple pass-through for testing
loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
}));
vi.mock('../components/MultiFolderTrustDialog.js', () => ({
MultiFolderTrustDialog: (props: MultiFolderTrustDialogProps) => (
<div data-testid="mock-dialog">{JSON.stringify(props.folders)}</div>
),
}));
describe('useIncludeDirsTrust', () => {
let mockConfig: Config;
let mockHistoryManager: UseHistoryManagerReturn;
let mockSetCustomDialog: Mock;
let mockWorkspaceContext: WorkspaceContext;
beforeEach(() => {
vi.clearAllMocks();
mockWorkspaceContext = {
addDirectory: vi.fn(),
getDirectories: vi.fn().mockReturnValue([]),
onDirectoriesChangedListeners: new Set(),
onDirectoriesChanged: vi.fn(),
notifyDirectoriesChanged: vi.fn(),
resolveAndValidateDir: vi.fn(),
getInitialDirectories: vi.fn(),
setDirectories: vi.fn(),
isPathWithinWorkspace: vi.fn(),
fullyResolvedPath: vi.fn(),
isPathWithinRoot: vi.fn(),
isFileSymlink: vi.fn(),
} as unknown as ReturnType<typeof mockConfig.getWorkspaceContext>;
mockConfig = {
getPendingIncludeDirectories: vi.fn().mockReturnValue([]),
clearPendingIncludeDirectories: vi.fn(),
getFolderTrust: vi.fn().mockReturnValue(true),
getWorkspaceContext: () => mockWorkspaceContext,
getGeminiClient: vi
.fn()
.mockReturnValue({ addDirectoryContext: vi.fn() }),
} as unknown as Config;
mockHistoryManager = {
addItem: vi.fn(),
history: [],
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
};
mockSetCustomDialog = vi.fn();
});
const renderTestHook = (isTrustedFolder: boolean | undefined) => {
renderHook(() =>
useIncludeDirsTrust(
mockConfig,
isTrustedFolder,
mockHistoryManager,
mockSetCustomDialog,
),
);
};
it('should do nothing if isTrustedFolder is undefined', () => {
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue([
'/foo',
]);
renderTestHook(undefined);
expect(mockConfig.clearPendingIncludeDirectories).not.toHaveBeenCalled();
});
it('should do nothing if there are no pending directories', () => {
renderTestHook(true);
expect(mockConfig.clearPendingIncludeDirectories).not.toHaveBeenCalled();
});
describe('when folder trust is disabled or workspace is untrusted', () => {
it.each([
{ trustEnabled: false, isTrusted: true, scenario: 'trust is disabled' },
{
trustEnabled: true,
isTrusted: false,
scenario: 'workspace is untrusted',
},
])(
'should add directories directly when $scenario',
async ({ trustEnabled, isTrusted }) => {
vi.mocked(mockConfig.getFolderTrust).mockReturnValue(trustEnabled);
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue([
'/dir1',
'/dir2',
]);
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
(path) => {
if (path === '/dir2') {
throw new Error('Test error');
}
},
);
renderTestHook(isTrusted);
await waitFor(() => {
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/dir1',
);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/dir2',
);
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("Error adding '/dir2': Test error"),
}),
expect.any(Number),
);
expect(
mockConfig.clearPendingIncludeDirectories,
).toHaveBeenCalledTimes(1);
});
},
);
});
describe('when folder trust is enabled and workspace is trusted', () => {
let mockIsPathTrusted: Mock;
beforeEach(() => {
vi.spyOn(mockConfig, 'getFolderTrust').mockReturnValue(true);
mockIsPathTrusted = vi.fn();
const mockLoadedFolders = {
isPathTrusted: mockIsPathTrusted,
} as unknown as LoadedTrustedFolders;
vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue(
mockLoadedFolders,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should add trusted dirs, collect untrusted errors, and open dialog for undefined', async () => {
const pendingDirs = ['/trusted', '/untrusted', '/undefined'];
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue(
pendingDirs,
);
mockIsPathTrusted.mockImplementation((path: string) => {
if (path === '/trusted') return true;
if (path === '/untrusted') return false;
return undefined;
});
renderTestHook(true);
// Opens dialog for undefined trust dir
expect(mockSetCustomDialog).toHaveBeenCalledTimes(1);
const customDialogAction = mockSetCustomDialog.mock.calls[0][0];
expect(customDialogAction).toBeDefined();
const dialogProps = (
customDialogAction as React.ReactElement<MultiFolderTrustDialogProps>
).props;
expect(dialogProps.folders).toEqual(['/undefined']);
expect(dialogProps.trustedDirs).toEqual(['/trusted']);
expect(dialogProps.errors as string[]).toEqual([
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- /untrusted\nPlease use the permissions command to modify their trust level.`,
]);
});
it('should only add directories and clear pending if no dialog is needed', async () => {
const pendingDirs = ['/trusted1', '/trusted2'];
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue(
pendingDirs,
);
mockIsPathTrusted.mockReturnValue(true);
renderTestHook(true);
await waitFor(() => {
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/trusted1',
);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/trusted2',
);
expect(mockSetCustomDialog).not.toHaveBeenCalled();
expect(mockConfig.clearPendingIncludeDirectories).toHaveBeenCalledTimes(
1,
);
});
});
});
});

View File

@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect } from 'react';
import type { Config } from '@google/gemini-cli-core';
import { loadTrustedFolders } from '../../config/trustedFolders.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type HistoryItem } from '../types.js';
async function finishAddingDirectories(
config: Config,
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number,
added: string[],
errors: string[],
) {
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
}
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
}
if (errors.length > 0) {
addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now());
}
}
export function useIncludeDirsTrust(
config: Config,
isTrustedFolder: boolean | undefined,
historyManager: UseHistoryManagerReturn,
setCustomDialog: (dialog: React.ReactNode | null) => void,
) {
const { addItem } = historyManager;
useEffect(() => {
// Don't run this until the initial trust is determined.
if (isTrustedFolder === undefined || !config) {
return;
}
const pendingDirs = config.getPendingIncludeDirectories();
if (pendingDirs.length === 0) {
return;
}
console.log('Inside useIncludeDirsTrust');
// If folder trust is disabled, isTrustedFolder will be undefined.
// In that case, or if the user decided not to trust the main folder,
// we can just add the directories without checking them.
if (config.getFolderTrust() === false || isTrustedFolder === false) {
const added: string[] = [];
const errors: string[] = [];
const workspaceContext = config.getWorkspaceContext();
for (const pathToAdd of pendingDirs) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
}
}
if (added.length > 0 || errors.length > 0) {
finishAddingDirectories(config, addItem, added, errors);
}
config.clearPendingIncludeDirectories();
return;
}
const trustedFolders = loadTrustedFolders();
const untrustedDirs: string[] = [];
const undefinedTrustDirs: string[] = [];
const trustedDirs: string[] = [];
const added: string[] = [];
const errors: string[] = [];
for (const pathToAdd of pendingDirs) {
const expandedPath = expandHomeDir(pathToAdd.trim());
const isTrusted = trustedFolders.isPathTrusted(expandedPath);
if (isTrusted === false) {
untrustedDirs.push(pathToAdd.trim());
} else if (isTrusted === undefined) {
undefinedTrustDirs.push(pathToAdd.trim());
} else {
trustedDirs.push(pathToAdd.trim());
}
}
if (untrustedDirs.length > 0) {
errors.push(
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join(
'\n- ',
)}\nPlease use the permissions command to modify their trust level.`,
);
}
const workspaceContext = config.getWorkspaceContext();
for (const pathToAdd of trustedDirs) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
added.push(pathToAdd);
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
}
}
if (undefinedTrustDirs.length > 0) {
console.log(
'Creating custom dialog with undecidedDirs:',
undefinedTrustDirs,
);
setCustomDialog(
<MultiFolderTrustDialog
folders={undefinedTrustDirs}
onComplete={() => {
setCustomDialog(null);
config.clearPendingIncludeDirectories();
}}
trustedDirs={added}
errors={errors}
finishAddingDirectories={finishAddingDirectories}
config={config}
addItem={addItem}
/>,
);
} else if (added.length > 0 || errors.length > 0) {
finishAddingDirectories(config, addItem, added, errors);
config.clearPendingIncludeDirectories();
}
}, [isTrustedFolder, config, addItem, setCustomDialog]);
}

View File

@@ -48,7 +48,9 @@ export const DefaultAppLayout: React.FC = () => {
<Notifications />
<CopyModeWarning />
{uiState.dialogsVisible ? (
{uiState.customDialog ? (
uiState.customDialog
) : uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.mainAreaWidth}
addItem={uiState.historyManager.addItem}

View File

@@ -27,5 +27,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
removeComponent: () => {},
};
}

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect } from 'vitest';
import { expandHomeDir } from './directoryUtils.js';
import type * as osActual from 'node:os';
import * as path from 'node:path';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: 'mock memory',
fileCount: 10,
filePaths: ['/a/b/c.md'],
}),
};
});
const mockHomeDir =
process.platform === 'win32' ? 'C:\\Users\\testuser' : '/home/testuser';
vi.mock('node:os', async (importOriginal) => {
const original = await importOriginal<typeof osActual>();
return {
...original,
homedir: vi.fn(() => mockHomeDir),
};
});
describe('directoryUtils', () => {
describe('expandHomeDir', () => {
it('should expand ~ to the home directory', () => {
expect(expandHomeDir('~')).toBe(mockHomeDir);
});
it('should expand ~/path to the home directory path', () => {
const expected = path.join(mockHomeDir, 'Documents');
expect(expandHomeDir('~/Documents')).toBe(expected);
});
it('should expand %userprofile% on Windows', () => {
if (process.platform === 'win32') {
const expected = path.join(mockHomeDir, 'Desktop');
expect(expandHomeDir('%userprofile%\\Desktop')).toBe(expected);
}
});
it('should not change a path that does not need expansion', () => {
const regularPath = path.join('usr', 'local', 'bin');
expect(expandHomeDir(regularPath)).toBe(regularPath);
});
it('should return an empty string if input is empty', () => {
expect(expandHomeDir('')).toBe('');
});
});
});

View File

@@ -430,7 +430,6 @@ describe('Server Config (config.ts)', () => {
});
it('should initialize WorkspaceContext with includeDirectories', () => {
const resolved = path.resolve(baseParams.targetDir);
const includeDirectories = ['dir1', 'dir2'];
const paramsWithIncludeDirs: ConfigParameters = {
...baseParams,
@@ -439,11 +438,13 @@ describe('Server Config (config.ts)', () => {
const config = new Config(paramsWithIncludeDirs);
const workspaceContext = config.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
// Should include the target directory plus the included directories
expect(directories).toHaveLength(3);
expect(directories).toContain(resolved);
expect(directories).toContain(path.join(resolved, 'dir1'));
expect(directories).toContain(path.join(resolved, 'dir2'));
// Should include only the target directory initially
expect(directories).toHaveLength(1);
expect(directories).toContain(path.resolve(baseParams.targetDir));
// The other directories should be in the pending list
expect(config.getPendingIncludeDirectories()).toEqual(includeDirectories);
});
it('Config constructor should set telemetry to true when provided as true', () => {

View File

@@ -411,6 +411,7 @@ export class Config {
readonly fakeResponses?: string;
readonly recordResponses?: string;
private readonly disableYoloMode: boolean;
private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly hooks:
| { [K in HookEventName]?: HookDefinition[] }
@@ -425,10 +426,9 @@ export class Config {
this.fileSystemService = new StandardFileSystemService();
this.sandbox = params.sandbox;
this.targetDir = path.resolve(params.targetDir);
this.workspaceContext = new WorkspaceContext(
this.targetDir,
params.includeDirectories ?? [],
);
this.folderTrust = params.folderTrust ?? false;
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
this.pendingIncludeDirectories = params.includeDirectories ?? [];
this.debugMode = params.debugMode;
this.question = params.question;
@@ -927,6 +927,14 @@ export class Config {
return this.disableYoloMode || !this.isTrustedFolder();
}
getPendingIncludeDirectories(): string[] {
return this.pendingIncludeDirectories;
}
clearPendingIncludeDirectories(): void {
this.pendingIncludeDirectories = [];
}
getShowMemoryUsage(): boolean {
return this.showMemoryUsage;
}