mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
Check folder trust before allowing add directory (#12652)
This commit is contained in:
259
packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx
Normal file
259
packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
176
packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
Normal file
176
packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user