diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 400d7432..d9460946 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -486,8 +486,8 @@ export async function loadCliConfig( ideModeFeature, chatCompression: settings.chatCompression, folderTrustFeature, - interactive, folderTrust, + interactive, }); } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 577133ca..82ba4fe1 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -203,6 +203,13 @@ vi.mock('./hooks/useAuthCommand', () => ({ })), })); +vi.mock('./hooks/useFolderTrust', () => ({ + useFolderTrust: vi.fn(() => ({ + isFolderTrustDialogOpen: false, + handleFolderTrustSelect: vi.fn(), + })), +})); + vi.mock('./hooks/useLogger', () => ({ useLogger: vi.fn(() => ({ getPreviousUserMessages: vi.fn().mockResolvedValue([]), @@ -1091,4 +1098,25 @@ describe('App UI', () => { expect(lastFrame()).toMatchSnapshot(); }); }); + + describe('FolderTrustDialog', () => { + it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => { + const { useFolderTrust } = await import('./hooks/useFolderTrust.js'); + vi.mocked(useFolderTrust).mockReturnValue({ + isFolderTrustDialogOpen: true, + handleFolderTrustSelect: vi.fn(), + }); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + await Promise.resolve(); + expect(lastFrame()).toContain('Do you trust this folder?'); + }); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 58a40b93..9550faa2 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -22,6 +22,7 @@ import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './hooks/useAuthCommand.js'; +import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; @@ -36,6 +37,7 @@ import { ThemeDialog } from './components/ThemeDialog.js'; import { AuthDialog } from './components/AuthDialog.js'; import { AuthInProgress } from './components/AuthInProgress.js'; import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; +import { FolderTrustDialog } from './components/FolderTrustDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { Colors } from './colors.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; @@ -240,6 +242,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { handleThemeHighlight, } = useThemeCommand(settings, setThemeError, addItem); + const { isFolderTrustDialogOpen, handleFolderTrustSelect } = + useFolderTrust(settings); + const { isAuthDialogOpen, openAuthDialog, @@ -905,6 +910,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { description="If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in VS Code." onComplete={handleIdePromptComplete} /> + ) : isFolderTrustDialogOpen ? ( + ) : shellConfirmationRequest ? ( ) : isThemeDialogOpen ? ( diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx new file mode 100644 index 00000000..01394d0f --- /dev/null +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { vi } from 'vitest'; +import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js'; + +describe('FolderTrustDialog', () => { + it('should render the dialog with title and description', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Do you trust this folder?'); + expect(lastFrame()).toContain( + 'Trusting a folder allows Gemini to execute commands it suggests.', + ); + }); + + it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => { + const onSelect = vi.fn(); + const { stdin } = render(); + + stdin.write('\u001B'); // Simulate escape key + + expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST); + }); +}); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx new file mode 100644 index 00000000..1918998c --- /dev/null +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text, useInput } from 'ink'; +import React from 'react'; +import { Colors } from '../colors.js'; +import { + RadioButtonSelect, + RadioSelectItem, +} from './shared/RadioButtonSelect.js'; + +export enum FolderTrustChoice { + TRUST_FOLDER = 'trust_folder', + TRUST_PARENT = 'trust_parent', + DO_NOT_TRUST = 'do_not_trust', +} + +interface FolderTrustDialogProps { + onSelect: (choice: FolderTrustChoice) => void; +} + +export const FolderTrustDialog: React.FC = ({ + onSelect, +}) => { + useInput((_, key) => { + if (key.escape) { + onSelect(FolderTrustChoice.DO_NOT_TRUST); + } + }); + + const options: Array> = [ + { + label: 'Trust folder', + value: FolderTrustChoice.TRUST_FOLDER, + }, + { + label: 'Trust parent folder', + value: FolderTrustChoice.TRUST_PARENT, + }, + { + label: "Don't trust (esc)", + value: FolderTrustChoice.DO_NOT_TRUST, + }, + ]; + + return ( + + + Do you trust this folder? + + Trusting a folder allows Gemini to execute commands it suggests. This + is a security feature to prevent accidental execution in untrusted + directories. + + + + + + ); +}; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts new file mode 100644 index 00000000..61552af0 --- /dev/null +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react'; +import { vi } from 'vitest'; +import { useFolderTrust } from './useFolderTrust.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; + +describe('useFolderTrust', () => { + it('should set isFolderTrustDialogOpen to true when folderTrustFeature is true and folderTrust is undefined', () => { + const settings = { + merged: { + folderTrustFeature: true, + folderTrust: undefined, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + + const { result } = renderHook(() => useFolderTrust(settings)); + + expect(result.current.isFolderTrustDialogOpen).toBe(true); + }); + + it('should set isFolderTrustDialogOpen to false when folderTrustFeature is false', () => { + const settings = { + merged: { + folderTrustFeature: false, + folderTrust: undefined, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + + const { result } = renderHook(() => useFolderTrust(settings)); + + expect(result.current.isFolderTrustDialogOpen).toBe(false); + }); + + it('should set isFolderTrustDialogOpen to false when folderTrust is defined', () => { + const settings = { + merged: { + folderTrustFeature: true, + folderTrust: true, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + + const { result } = renderHook(() => useFolderTrust(settings)); + + expect(result.current.isFolderTrustDialogOpen).toBe(false); + }); + + it('should call setValue and set isFolderTrustDialogOpen to false on handleFolderTrustSelect', () => { + const settings = { + merged: { + folderTrustFeature: true, + folderTrust: undefined, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + + const { result } = renderHook(() => useFolderTrust(settings)); + + act(() => { + result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); + }); + + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'folderTrust', + true, + ); + expect(result.current.isFolderTrustDialogOpen).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts new file mode 100644 index 00000000..90a69132 --- /dev/null +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; + +export const useFolderTrust = (settings: LoadedSettings) => { + const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState( + !!settings.merged.folderTrustFeature && + // TODO: Update to avoid showing dialog for folders that are trusted. + settings.merged.folderTrust === undefined, + ); + + const handleFolderTrustSelect = useCallback( + (_choice: FolderTrustChoice) => { + // TODO: Store folderPath in the trusted folders config file based on the choice. + settings.setValue(SettingScope.User, 'folderTrust', true); + setIsFolderTrustDialogOpen(false); + }, + [settings], + ); + + return { + isFolderTrustDialogOpen, + handleFolderTrustSelect, + }; +};