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

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