From 6c559e233850782b22fc406e9ab606332ee896dc Mon Sep 17 00:00:00 2001 From: shrutip90 Date: Mon, 22 Sep 2025 11:45:02 -0700 Subject: [PATCH] feat(cli): Add permissions command to modify trust settings (#8792) --- packages/cli/src/config/config.test.ts | 9 +- packages/cli/src/config/config.ts | 2 +- .../cli/src/config/extensions/update.test.ts | 5 +- packages/cli/src/config/settings.test.ts | 19 +- packages/cli/src/config/settings.ts | 4 +- .../cli/src/config/trustedFolders.test.ts | 54 +++- packages/cli/src/config/trustedFolders.ts | 30 +- .../src/services/BuiltinCommandLoader.test.ts | 29 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/App.test.tsx | 7 + packages/cli/src/ui/App.tsx | 6 +- packages/cli/src/ui/AppContainer.tsx | 20 +- .../ui/commands/permissionsCommand.test.ts | 35 +++ .../cli/src/ui/commands/permissionsCommand.ts | 18 ++ packages/cli/src/ui/commands/types.ts | 9 +- .../cli/src/ui/components/DialogManager.tsx | 17 +- .../PermissionsModifyTrustDialog.test.tsx | 204 ++++++++++++++ .../PermissionsModifyTrustDialog.tsx | 122 ++++++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 4 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + .../src/ui/hooks/useExtensionUpdates.test.ts | 5 +- .../cli/src/ui/hooks/useFolderTrust.test.ts | 45 +-- packages/cli/src/ui/hooks/useFolderTrust.ts | 2 +- .../hooks/usePermissionsModifyTrust.test.ts | 263 ++++++++++++++++++ .../src/ui/hooks/usePermissionsModifyTrust.ts | 128 +++++++++ 26 files changed, 991 insertions(+), 53 deletions(-) create mode 100644 packages/cli/src/ui/commands/permissionsCommand.test.ts create mode 100644 packages/cli/src/ui/commands/permissionsCommand.ts create mode 100644 packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx create mode 100644 packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx create mode 100644 packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts create mode 100644 packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index a02ecb70a3..ae9369d61b 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -22,7 +22,9 @@ import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; vi.mock('./trustedFolders.js', () => ({ - isWorkspaceTrusted: vi.fn().mockReturnValue(true), // Default to trusted + isWorkspaceTrusted: vi + .fn() + .mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted })); vi.mock('fs', async (importOriginal) => { @@ -2098,7 +2100,10 @@ describe('loadCliConfig approval mode', () => { // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); }); it('should override --approval-mode=yolo to DEFAULT', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2640d51758..80dcb88d4e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -414,7 +414,7 @@ export async function loadCliConfig( const ideMode = settings.ide?.enabled ?? false; const folderTrust = settings.security?.folderTrust?.enabled ?? false; - const trustedFolder = isWorkspaceTrusted(settings) ?? true; + const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true; const allExtensions = annotateActiveExtensions( extensions, diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index ac69b56500..ee608c57b4 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -87,7 +87,10 @@ describe('update tests', () => { // Clean up before each test fs.rmSync(userExtensionsDir, { recursive: true, force: true }); fs.mkdirSync(userExtensionsDir, { recursive: true }); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); Object.values(mockGit).forEach((fn) => fn.mockReset()); }); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 15a0535e0f..a8b039b827 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -30,7 +30,9 @@ vi.mock('./settings.js', async (importActual) => { // Mock trustedFolders vi.mock('./trustedFolders.js', () => ({ - isWorkspaceTrusted: vi.fn(), + isWorkspaceTrusted: vi + .fn() + .mockReturnValue({ isTrusted: true, source: 'file' }), })); // NOW import everything else, including the (now effectively re-exported) settings.js @@ -120,7 +122,10 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(false); (fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON (mockFsMkdirSync as Mock).mockImplementation(() => undefined); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); }); afterEach(() => { @@ -1843,7 +1848,10 @@ describe('Settings Loading and Merging', () => { }); it('should NOT merge workspace settings when workspace is not trusted', () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { ui: { theme: 'dark' }, @@ -2222,7 +2230,10 @@ describe('Settings Loading and Merging', () => { delete process.env['TESTTEST']; // reset const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env')); - vi.mocked(isWorkspaceTrusted).mockReturnValue(isWorkspaceTrustedValue); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: isWorkspaceTrustedValue, + source: 'file', + }); (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => [USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()), ); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 97feb171b3..6d73722eb1 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -492,7 +492,7 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void { export function loadEnvironment(settings: Settings): void { const envFilePath = findEnvFile(process.cwd()); - if (!isWorkspaceTrusted(settings)) { + if (!isWorkspaceTrusted(settings).isTrusted) { return; } @@ -674,7 +674,7 @@ export function loadSettings( userSettings, ); const isTrusted = - isWorkspaceTrusted(initialTrustCheckSettings as Settings) ?? true; + isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 6676d3dbec..150bffbdf4 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -229,52 +229,70 @@ describe('isWorkspaceTrusted', () => { it('should return true for a directly trusted folder', () => { mockCwd = '/home/user/projectA'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should return true for a child of a trusted folder', () => { mockCwd = '/home/user/projectA/src'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should return true for a child of a trusted parent folder', () => { mockCwd = '/home/user/projectB'; mockRules['/home/user/projectB/somefile.txt'] = TrustLevel.TRUST_PARENT; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should return false for a directly untrusted folder', () => { mockCwd = '/home/user/untrusted'; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBe(false); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: false, + source: 'file', + }); }); it('should return undefined for a child of an untrusted folder', () => { mockCwd = '/home/user/untrusted/src'; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBeUndefined(); + expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); }); it('should return undefined when no rules match', () => { mockCwd = '/home/user/other'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBeUndefined(); + expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); }); it('should prioritize trust over distrust', () => { mockCwd = '/home/user/projectA/untrusted'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should handle path normalization', () => { mockCwd = '/home/user/projectA'; mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); }); @@ -297,7 +315,10 @@ describe('isWorkspaceTrusted with IDE override', () => { vi.spyOn(fs, 'readFileSync').mockReturnValue( JSON.stringify({ [process.cwd()]: TrustLevel.DO_NOT_TRUST }), ); - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'ide', + }); }); it('should return false when ideTrust is false, ignoring config', () => { @@ -306,7 +327,10 @@ describe('isWorkspaceTrusted with IDE override', () => { vi.spyOn(fs, 'readFileSync').mockReturnValue( JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }), ); - expect(isWorkspaceTrusted(mockSettings)).toBe(false); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: false, + source: 'ide', + }); }); it('should fall back to config when ideTrust is undefined', () => { @@ -314,7 +338,10 @@ describe('isWorkspaceTrusted with IDE override', () => { vi.spyOn(fs, 'readFileSync').mockReturnValue( JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }), ); - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should always return true if folderTrust setting is disabled', () => { @@ -326,6 +353,9 @@ describe('isWorkspaceTrusted with IDE override', () => { }, }; ideContextStore.set({ workspaceState: { isTrusted: false } }); - expect(isWorkspaceTrusted(settings)).toBe(true); + expect(isWorkspaceTrusted(settings)).toEqual({ + isTrusted: true, + source: undefined, + }); }); }); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 3a8943f464..dcc27d6904 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -47,6 +47,11 @@ export interface TrustedFoldersFile { path: string; } +export interface TrustResult { + isTrusted: boolean | undefined; + source: 'ide' | 'file' | undefined; +} + export class LoadedTrustedFolders { constructor( readonly user: TrustedFoldersFile, @@ -166,9 +171,15 @@ export function isFolderTrustEnabled(settings: Settings): boolean { return folderTrustSetting; } -function getWorkspaceTrustFromLocalConfig(): boolean | undefined { +function getWorkspaceTrustFromLocalConfig( + trustConfig?: Record, +): TrustResult { const folders = loadTrustedFolders(); + if (trustConfig) { + folders.user.config = trustConfig; + } + if (folders.errors.length > 0) { for (const error of folders.errors) { console.error( @@ -177,19 +188,26 @@ function getWorkspaceTrustFromLocalConfig(): boolean | undefined { } } - return folders.isPathTrusted(process.cwd()); + const isTrusted = folders.isPathTrusted(process.cwd()); + return { + isTrusted, + source: isTrusted !== undefined ? 'file' : undefined, + }; } -export function isWorkspaceTrusted(settings: Settings): boolean | undefined { +export function isWorkspaceTrusted( + settings: Settings, + trustConfig?: Record, +): TrustResult { if (!isFolderTrustEnabled(settings)) { - return true; + return { isTrusted: true, source: undefined }; } const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted; if (ideTrust !== undefined) { - return ideTrust; + return { isTrusted: ideTrust, source: 'ide' }; } // Fall back to the local user configuration - return getWorkspaceTrustFromLocalConfig(); + return getWorkspaceTrustFromLocalConfig(trustConfig); } diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index ee80afbd73..cce3cb204f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -28,6 +28,16 @@ vi.mock('../ui/commands/ideCommand.js', async () => { vi.mock('../ui/commands/restoreCommand.js', () => ({ restoreCommand: vi.fn(), })); +vi.mock('../ui/commands/permissionsCommand.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + permissionsCommand: { + name: 'permissions', + description: 'Permissions command', + kind: CommandKind.BUILT_IN, + }, + }; +}); import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { BuiltinCommandLoader } from './BuiltinCommandLoader.js'; @@ -69,7 +79,9 @@ describe('BuiltinCommandLoader', () => { beforeEach(() => { vi.clearAllMocks(); - mockConfig = { some: 'config' } as unknown as Config; + mockConfig = { + getFolderTrust: vi.fn().mockReturnValue(true), + } as unknown as Config; restoreCommandMock.mockReturnValue({ name: 'restore', @@ -123,4 +135,19 @@ describe('BuiltinCommandLoader', () => { const mcpCmd = commands.find((c) => c.name === 'mcp'); expect(mcpCmd).toBeDefined(); }); + + it('should include permissions command when folder trust is enabled', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const permissionsCmd = commands.find((c) => c.name === 'permissions'); + expect(permissionsCmd).toBeDefined(); + }); + + it('should exclude permissions command when folder trust is disabled', async () => { + (mockConfig.getFolderTrust as Mock).mockReturnValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const permissionsCmd = commands.find((c) => c.name === 'permissions'); + expect(permissionsCmd).toBeUndefined(); + }); }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index e4cf330f4b..08f9834403 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -24,6 +24,7 @@ import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; +import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; @@ -68,6 +69,7 @@ export class BuiltinCommandLoader implements ICommandLoader { initCommand, mcpCommand, memoryCommand, + this.config?.getFolderTrust() ? permissionsCommand : null, privacyCommand, quitCommand, restoreCommand(this.config), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 4b848c41a4..aad5e51211 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -38,6 +38,13 @@ describe('App', () => { quittingMessages: null, dialogsVisible: false, mainControlsRef: { current: null }, + historyManager: { + addItem: vi.fn(), + history: [], + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }, }; it('should render main content and composer when not quitting', () => { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 34d9bd7e54..8a582be7f7 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -29,7 +29,11 @@ export const App = () => { - {uiState.dialogsVisible ? : } + {uiState.dialogsVisible ? ( + + ) : ( + + )} {uiState.dialogsVisible && uiState.ctrlCPressedOnce && ( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1c332ff432..8d22baddb0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -157,6 +157,16 @@ export const AppContainer = (props: AppContainerProps) => { config.getWorkingDir(), ); + const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); + const openPermissionsDialog = useCallback( + () => setPermissionsDialogOpen(true), + [], + ); + const closePermissionsDialog = useCallback( + () => setPermissionsDialogOpen(false), + [], + ); + // Helper to determine the effective model, considering the fallback state. const getEffectiveModel = useCallback(() => { if (config.isInFallbackMode()) { @@ -424,6 +434,7 @@ Logging in with Google... Please restart Gemini CLI to continue. openEditorDialog, openPrivacyNotice: () => setShowPrivacyNotice(true), openSettingsDialog, + openPermissionsDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); setTimeout(async () => { @@ -445,6 +456,7 @@ Logging in with Google... Please restart Gemini CLI to continue. setShowPrivacyNotice, setCorgiMode, setExtensionsUpdateState, + openPermissionsDialog, ], ); @@ -985,6 +997,7 @@ Logging in with Google... Please restart Gemini CLI to continue. !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || + isPermissionsDialogOpen || isAuthenticating || isAuthDialogOpen || isEditorDialogOpen || @@ -999,6 +1012,7 @@ Logging in with Google... Please restart Gemini CLI to continue. const uiState: UIState = useMemo( () => ({ history: historyManager.history, + historyManager, isThemeDialogOpen, themeError, isAuthenticating, @@ -1012,6 +1026,7 @@ Logging in with Google... Please restart Gemini CLI to continue. debugMessage, quittingMessages, isSettingsDialogOpen, + isPermissionsDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1074,7 +1089,6 @@ Logging in with Google... Please restart Gemini CLI to continue. embeddedShellFocused, }), [ - historyManager.history, isThemeDialogOpen, themeError, isAuthenticating, @@ -1088,6 +1102,7 @@ Logging in with Google... Please restart Gemini CLI to continue. debugMessage, quittingMessages, isSettingsDialogOpen, + isPermissionsDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1147,6 +1162,7 @@ Logging in with Google... Please restart Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, + historyManager, embeddedShellFocused, ], ); @@ -1162,6 +1178,7 @@ Logging in with Google... Please restart Gemini CLI to continue. exitEditorDialog, exitPrivacyNotice: () => setShowPrivacyNotice(false), closeSettingsDialog, + closePermissionsDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, @@ -1184,6 +1201,7 @@ Logging in with Google... Please restart Gemini CLI to continue. handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closePermissionsDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, diff --git a/packages/cli/src/ui/commands/permissionsCommand.test.ts b/packages/cli/src/ui/commands/permissionsCommand.test.ts new file mode 100644 index 0000000000..f51e7c3df4 --- /dev/null +++ b/packages/cli/src/ui/commands/permissionsCommand.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { permissionsCommand } from './permissionsCommand.js'; +import { type CommandContext, CommandKind } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('permissionsCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have the correct name and description', () => { + expect(permissionsCommand.name).toBe('permissions'); + expect(permissionsCommand.description).toBe('Manage folder trust settings'); + }); + + it('should be a built-in command', () => { + expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should return an action to open the permissions dialog', () => { + const actionResult = permissionsCommand.action?.(mockContext, ''); + expect(actionResult).toEqual({ + type: 'dialog', + dialog: 'permissions', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts new file mode 100644 index 0000000000..60ef388439 --- /dev/null +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { OpenDialogActionReturn, SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; + +export const permissionsCommand: SlashCommand = { + name: 'permissions', + description: 'Manage folder trust settings', + kind: CommandKind.BUILT_IN, + action: (): OpenDialogActionReturn => ({ + type: 'dialog', + dialog: 'permissions', + }), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 755b28993a..cb12d62b39 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -108,7 +108,14 @@ export interface MessageActionReturn { export interface OpenDialogActionReturn { type: 'dialog'; - dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings'; + dialog: + | 'help' + | 'auth' + | 'theme' + | 'editor' + | 'privacy' + | 'settings' + | 'permissions'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 6c871560d0..d65ad5f101 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -18,15 +18,21 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; +import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import process from 'node:process'; +import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; + +interface DialogManagerProps { + addItem: UseHistoryManagerReturn['addItem']; +} // Props for DialogManager -export const DialogManager = () => { +export const DialogManager = ({ addItem }: DialogManagerProps) => { const config = useConfig(); const settings = useSettings(); @@ -188,5 +194,14 @@ export const DialogManager = () => { ); } + if (uiState.isPermissionsDialogOpen) { + return ( + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx new file mode 100644 index 0000000000..6bb18318ce --- /dev/null +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; +import { TrustLevel } from '../../config/trustedFolders.js'; +import { waitFor, act } from '@testing-library/react'; +import * as processUtils from '../../utils/processUtils.js'; +import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; + +// Hoist mocks for dependencies of the usePermissionsModifyTrust hook +const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn()); +const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); +const mockedUseSettings = vi.hoisted(() => vi.fn()); + +// Mock the modules themselves +vi.mock('node:process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cwd: mockedCwd, + }; +}); + +vi.mock('../../config/trustedFolders.js', () => ({ + loadTrustedFolders: mockedLoadTrustedFolders, + isWorkspaceTrusted: mockedIsWorkspaceTrusted, + TrustLevel: { + TRUST_FOLDER: 'TRUST_FOLDER', + TRUST_PARENT: 'TRUST_PARENT', + DO_NOT_TRUST: 'DO_NOT_TRUST', + }, +})); + +vi.mock('../contexts/SettingsContext.js', () => ({ + useSettings: mockedUseSettings, +})); + +vi.mock('../hooks/usePermissionsModifyTrust.js'); + +describe('PermissionsModifyTrustDialog', () => { + let mockUpdateTrustLevel: Mock; + let mockCommitTrustLevelChange: Mock; + + beforeEach(() => { + mockUpdateTrustLevel = vi.fn(); + mockCommitTrustLevelChange = vi.fn(); + vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + cwd: '/test/dir', + currentTrustLevel: TrustLevel.DO_NOT_TRUST, + isInheritedTrustFromParent: false, + isInheritedTrustFromIde: false, + needsRestart: false, + updateTrustLevel: mockUpdateTrustLevel, + commitTrustLevelChange: mockCommitTrustLevelChange, + isFolderTrustEnabled: true, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should render the main dialog with current trust level', async () => { + const { lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain('Modify Trust Level'); + expect(lastFrame()).toContain('Folder: /test/dir'); + expect(lastFrame()).toContain('Current Level: DO_NOT_TRUST'); + }); + }); + + it('should display the inherited trust note from parent', async () => { + vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + cwd: '/test/dir', + currentTrustLevel: TrustLevel.DO_NOT_TRUST, + isInheritedTrustFromParent: true, + isInheritedTrustFromIde: false, + needsRestart: false, + updateTrustLevel: mockUpdateTrustLevel, + commitTrustLevelChange: mockCommitTrustLevelChange, + isFolderTrustEnabled: true, + }); + const { lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain( + 'Note: This folder behaves as a trusted folder because one of the parent folders is trusted.', + ); + }); + }); + + it('should display the inherited trust note from IDE', async () => { + vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + cwd: '/test/dir', + currentTrustLevel: TrustLevel.DO_NOT_TRUST, + isInheritedTrustFromParent: false, + isInheritedTrustFromIde: true, + needsRestart: false, + updateTrustLevel: mockUpdateTrustLevel, + commitTrustLevelChange: mockCommitTrustLevelChange, + isFolderTrustEnabled: true, + }); + const { lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain( + 'Note: This folder behaves as a trusted folder because the connected IDE workspace is trusted.', + ); + }); + }); + + it('should call onExit when escape is pressed', async () => { + const onExit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + + act(() => { + stdin.write('\x1b'); // escape key + }); + + await waitFor(() => { + expect(onExit).toHaveBeenCalled(); + }); + }); + + it('should commit, restart, and exit on `r` keypress', async () => { + const mockRelaunchApp = vi + .spyOn(processUtils, 'relaunchApp') + .mockResolvedValue(undefined); + vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + cwd: '/test/dir', + currentTrustLevel: TrustLevel.DO_NOT_TRUST, + isInheritedTrustFromParent: false, + isInheritedTrustFromIde: false, + needsRestart: true, + updateTrustLevel: mockUpdateTrustLevel, + commitTrustLevelChange: mockCommitTrustLevelChange, + isFolderTrustEnabled: true, + }); + + const onExit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + + act(() => stdin.write('r')); // Press 'r' to restart + + await waitFor(() => { + expect(mockCommitTrustLevelChange).toHaveBeenCalled(); + expect(mockRelaunchApp).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + }); + + mockRelaunchApp.mockRestore(); + }); + + it('should not commit when escape is pressed during restart prompt', async () => { + vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + cwd: '/test/dir', + currentTrustLevel: TrustLevel.DO_NOT_TRUST, + isInheritedTrustFromParent: false, + isInheritedTrustFromIde: false, + needsRestart: true, + updateTrustLevel: mockUpdateTrustLevel, + commitTrustLevelChange: mockCommitTrustLevelChange, + isFolderTrustEnabled: true, + }); + + const onExit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + + act(() => stdin.write('\x1b')); // Press escape + + await waitFor(() => { + expect(mockCommitTrustLevelChange).not.toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx new file mode 100644 index 0000000000..4704c64b6d --- /dev/null +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; +import { TrustLevel } from '../../config/trustedFolders.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { relaunchApp } from '../../utils/processUtils.js'; +import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; + +interface PermissionsModifyTrustDialogProps { + onExit: () => void; + addItem: UseHistoryManagerReturn['addItem']; +} + +const TRUST_LEVEL_ITEMS = [ + { + label: 'Trust this folder', + value: TrustLevel.TRUST_FOLDER, + }, + { + label: 'Trust parent folder', + value: TrustLevel.TRUST_PARENT, + }, + { + label: "Don't trust", + value: TrustLevel.DO_NOT_TRUST, + }, +]; + +export function PermissionsModifyTrustDialog({ + onExit, + addItem, +}: PermissionsModifyTrustDialogProps): React.JSX.Element { + const { + cwd, + currentTrustLevel, + isInheritedTrustFromParent, + isInheritedTrustFromIde, + needsRestart, + updateTrustLevel, + commitTrustLevelChange, + } = usePermissionsModifyTrust(onExit, addItem); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onExit(); + } + if (needsRestart && key.name === 'r') { + commitTrustLevelChange(); + relaunchApp(); + onExit(); + } + }, + { isActive: true }, + ); + + const index = TRUST_LEVEL_ITEMS.findIndex( + (item) => item.value === currentTrustLevel, + ); + const initialIndex = index === -1 ? 0 : index; + + return ( + <> + + + {'> '}Modify Trust Level + + Folder: {cwd} + + Current Level: {currentTrustLevel || 'Not Set'} + + {isInheritedTrustFromParent && ( + + Note: This folder behaves as a trusted folder because one of the + parent folders is trusted. It will remain trusted even if you set + a different trust level here. To change this, you need to modify + the trust setting in the parent folder. + + )} + {isInheritedTrustFromIde && ( + + Note: This folder behaves as a trusted folder because the + connected IDE workspace is trusted. It will remain trusted even if + you set a different trust level here. + + )} + + + + + (Use Enter to select) + + + {needsRestart && ( + + + To apply the trust changes, Gemini CLI must be restarted. Press + 'r' to restart CLI now. + + + )} + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 2a88fd3097..1ef8c3420d 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -31,6 +31,7 @@ export interface UIActions { exitEditorDialog: () => void; exitPrivacyNotice: () => void; closeSettingsDialog: () => void; + closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 693652bc96..8575dce3aa 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -35,8 +35,11 @@ export interface ProQuotaDialogRequest { resolve: (intent: FallbackIntent) => void; } +import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; + export interface UIState { history: HistoryItem[]; + historyManager: UseHistoryManagerReturn; isThemeDialogOpen: boolean; themeError: string | null; isAuthenticating: boolean; @@ -50,6 +53,7 @@ export interface UIState { debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; + isPermissionsDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index acb43286cb..962efb715f 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -49,6 +49,7 @@ interface SlashCommandProcessorActions { openEditorDialog: () => void; openPrivacyNotice: () => void; openSettingsDialog: () => void; + openPermissionsDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; @@ -373,6 +374,9 @@ export const useSlashCommandProcessor = ( case 'settings': actions.openSettingsDialog(); return { type: 'handled' }; + case 'permissions': + actions.openPermissionsDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index c9e04cab86..5f621385bf 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -187,7 +187,10 @@ describe('useExtensionUpdates', () => { JSON.stringify({ name: 'test-extension', version: '1.1.0' }), ); }); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir)); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index e51e3636be..cdb66e9f85 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -56,7 +56,7 @@ describe('useFolderTrust', () => { }); it('should not open dialog when folder is already trusted', () => { - isWorkspaceTrustedSpy.mockReturnValue(true); + isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: true, source: 'file' }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -65,7 +65,7 @@ describe('useFolderTrust', () => { }); it('should not open dialog when folder is already untrusted', () => { - isWorkspaceTrustedSpy.mockReturnValue(false); + isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file' }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -74,7 +74,10 @@ describe('useFolderTrust', () => { }); it('should open dialog when folder trust is undefined', () => { - isWorkspaceTrustedSpy.mockReturnValue(undefined); + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -83,14 +86,14 @@ describe('useFolderTrust', () => { }); it('should handle TRUST_FOLDER choice', () => { - isWorkspaceTrustedSpy - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(true); + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); - isWorkspaceTrustedSpy.mockReturnValue(true); act(() => { result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); }); @@ -105,9 +108,10 @@ describe('useFolderTrust', () => { }); it('should handle TRUST_PARENT choice', () => { - isWorkspaceTrustedSpy - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(true); + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -125,9 +129,10 @@ describe('useFolderTrust', () => { }); it('should handle DO_NOT_TRUST choice and trigger restart', () => { - isWorkspaceTrustedSpy - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(false); + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -146,7 +151,10 @@ describe('useFolderTrust', () => { }); it('should do nothing for default choice', () => { - isWorkspaceTrustedSpy.mockReturnValue(undefined); + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -164,7 +172,7 @@ describe('useFolderTrust', () => { }); it('should set isRestarting to true when trust status changes from false to true', () => { - isWorkspaceTrustedSpy.mockReturnValueOnce(false).mockReturnValueOnce(true); // Initially untrusted, then trusted + isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file' }); // Initially untrusted const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); @@ -178,9 +186,10 @@ describe('useFolderTrust', () => { }); it('should not set isRestarting to true when trust status does not change', () => { - isWorkspaceTrustedSpy - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(true); // Initially undefined, then trust + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange), ); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index db2f8d62c9..76d63150d7 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -25,7 +25,7 @@ export const useFolderTrust = ( const folderTrust = settings.merged.security?.folderTrust?.enabled; useEffect(() => { - const trusted = isWorkspaceTrusted(settings.merged); + const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); setIsTrusted(trusted); setIsFolderTrustDialogOpen(trusted === undefined); onTrustChange(trusted); diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts new file mode 100644 index 0000000000..519752e82b --- /dev/null +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { usePermissionsModifyTrust } from './usePermissionsModifyTrust.js'; +import { TrustLevel } from '../../config/trustedFolders.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; + +// Hoist mocks +const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn()); +const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); +const mockedUseSettings = vi.hoisted(() => vi.fn()); + +// Mock modules +vi.mock('node:process', () => ({ + cwd: mockedCwd, +})); + +vi.mock('../../config/trustedFolders.js', () => ({ + loadTrustedFolders: mockedLoadTrustedFolders, + isWorkspaceTrusted: mockedIsWorkspaceTrusted, + TrustLevel: { + TRUST_FOLDER: 'TRUST_FOLDER', + TRUST_PARENT: 'TRUST_PARENT', + DO_NOT_TRUST: 'DO_NOT_TRUST', + }, +})); + +vi.mock('../contexts/SettingsContext.js', () => ({ + useSettings: mockedUseSettings, +})); + +describe('usePermissionsModifyTrust', () => { + let mockOnExit: Mock; + let mockAddItem: Mock; + + beforeEach(() => { + mockAddItem = vi.fn(); + mockOnExit = vi.fn(); + + mockedCwd.mockReturnValue('/test/dir'); + mockedUseSettings.mockReturnValue({ + merged: { + security: { + folderTrust: { + enabled: true, + }, + }, + }, + } as LoadedSettings); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should initialize with the correct trust level', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: { '/test/dir': TrustLevel.TRUST_FOLDER } }, + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER); + }); + + it('should detect inherited trust from parent', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + expect(result.current.isInheritedTrustFromParent).toBe(true); + expect(result.current.isInheritedTrustFromIde).toBe(false); + }); + + it('should detect inherited trust from IDE', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, // No explicit trust + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'ide', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + expect(result.current.isInheritedTrustFromIde).toBe(true); + expect(result.current.isInheritedTrustFromParent).toBe(false); + }); + + it('should set needsRestart but not save when trust changes', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + + mockedIsWorkspaceTrusted + .mockReturnValueOnce({ isTrusted: false, source: 'file' }) + .mockReturnValueOnce({ isTrusted: true, source: 'file' }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); + }); + + expect(result.current.needsRestart).toBe(true); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should save immediately if trust does not change', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_PARENT); + }); + + expect(result.current.needsRestart).toBe(false); + expect(mockSetValue).toHaveBeenCalledWith( + '/test/dir', + TrustLevel.TRUST_PARENT, + ); + expect(mockOnExit).toHaveBeenCalled(); + }); + + it('should commit the pending trust level change', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + + mockedIsWorkspaceTrusted + .mockReturnValueOnce({ isTrusted: false, source: 'file' }) + .mockReturnValueOnce({ isTrusted: true, source: 'file' }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); + }); + + expect(result.current.needsRestart).toBe(true); + + act(() => { + result.current.commitTrustLevelChange(); + }); + + expect(mockSetValue).toHaveBeenCalledWith( + '/test/dir', + TrustLevel.TRUST_FOLDER, + ); + }); + + it('should add warning when setting DO_NOT_TRUST but still trusted by parent', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: 'warning', + text: 'Note: This folder is still trusted because a parent folder is trusted.', + }, + expect.any(Number), + ); + }); + + it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'ide', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: 'warning', + text: 'Note: This folder is still trusted because the connected IDE workspace is trusted.', + }, + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts new file mode 100644 index 0000000000..f5a10ff38f --- /dev/null +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import * as process from 'node:process'; +import { + loadTrustedFolders, + TrustLevel, + isWorkspaceTrusted, +} from '../../config/trustedFolders.js'; +import { useSettings } from '../contexts/SettingsContext.js'; + +import { MessageType } from '../types.js'; +import { type UseHistoryManagerReturn } from './useHistoryManager.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +interface TrustState { + currentTrustLevel: TrustLevel | undefined; + isInheritedTrustFromParent: boolean; + isInheritedTrustFromIde: boolean; +} + +function getInitialTrustState( + settings: LoadedSettings, + cwd: string, +): TrustState { + const folders = loadTrustedFolders(); + const explicitTrustLevel = folders.user.config[cwd]; + const { isTrusted, source } = isWorkspaceTrusted(settings.merged); + + const isInheritedTrust = + isTrusted && + (!explicitTrustLevel || explicitTrustLevel === TrustLevel.DO_NOT_TRUST); + + return { + currentTrustLevel: explicitTrustLevel, + isInheritedTrustFromParent: !!(source === 'file' && isInheritedTrust), + isInheritedTrustFromIde: !!(source === 'ide' && isInheritedTrust), + }; +} + +export const usePermissionsModifyTrust = ( + onExit: () => void, + addItem: UseHistoryManagerReturn['addItem'], +) => { + const settings = useSettings(); + const cwd = process.cwd(); + + const [initialState] = useState(() => getInitialTrustState(settings, cwd)); + + const [currentTrustLevel] = useState( + initialState.currentTrustLevel, + ); + const [pendingTrustLevel, setPendingTrustLevel] = useState< + TrustLevel | undefined + >(); + const [isInheritedTrustFromParent] = useState( + initialState.isInheritedTrustFromParent, + ); + const [isInheritedTrustFromIde] = useState( + initialState.isInheritedTrustFromIde, + ); + const [needsRestart, setNeedsRestart] = useState(false); + + const isFolderTrustEnabled = !!settings.merged.security?.folderTrust?.enabled; + + const updateTrustLevel = useCallback( + (trustLevel: TrustLevel) => { + const wasTrusted = isWorkspaceTrusted(settings.merged).isTrusted; + + // Create a temporary config to check the new trust status without writing + const currentConfig = loadTrustedFolders().user.config; + const newConfig = { ...currentConfig, [cwd]: trustLevel }; + + const { isTrusted, source } = isWorkspaceTrusted( + settings.merged, + newConfig, + ); + + if (trustLevel === TrustLevel.DO_NOT_TRUST && isTrusted) { + let message = + 'Note: This folder is still trusted because the connected IDE workspace is trusted.'; + if (source === 'file') { + message = + 'Note: This folder is still trusted because a parent folder is trusted.'; + } + addItem( + { + type: MessageType.WARNING, + text: message, + }, + Date.now(), + ); + } + + if (wasTrusted !== isTrusted) { + setPendingTrustLevel(trustLevel); + setNeedsRestart(true); + } else { + const folders = loadTrustedFolders(); + folders.setValue(cwd, trustLevel); + onExit(); + } + }, + [cwd, settings.merged, onExit, addItem], + ); + + const commitTrustLevelChange = useCallback(() => { + if (pendingTrustLevel) { + const folders = loadTrustedFolders(); + folders.setValue(cwd, pendingTrustLevel); + } + }, [cwd, pendingTrustLevel]); + + return { + cwd, + currentTrustLevel, + isInheritedTrustFromParent, + isInheritedTrustFromIde, + needsRestart, + updateTrustLevel, + commitTrustLevelChange, + isFolderTrustEnabled, + }; +};