feat: Update permissions command to support modifying trust for other… (#11642)

This commit is contained in:
shrutip90
2025-11-14 14:41:53 -08:00
committed by GitHub
parent ce56b4ee1b
commit 472e775a13
14 changed files with 498 additions and 212 deletions
+15 -5
View File
@@ -196,14 +196,20 @@ export const AppContainer = (props: AppContainerProps) => {
); );
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const [permissionsDialogProps, setPermissionsDialogProps] = useState<{
targetDirectory?: string;
} | null>(null);
const openPermissionsDialog = useCallback( const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true), (props?: { targetDirectory?: string }) => {
[], setPermissionsDialogOpen(true);
); setPermissionsDialogProps(props ?? null);
const closePermissionsDialog = useCallback( },
() => setPermissionsDialogOpen(false),
[], [],
); );
const closePermissionsDialog = useCallback(() => {
setPermissionsDialogOpen(false);
setPermissionsDialogProps(null);
}, []);
const toggleDebugProfiler = useCallback( const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev), () => setShowDebugProfiler((prev) => !prev),
@@ -1301,6 +1307,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
isSettingsDialogOpen, isSettingsDialogOpen,
isModelDialogOpen, isModelDialogOpen,
isPermissionsDialogOpen, isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands, slashCommands,
pendingSlashCommandHistoryItems, pendingSlashCommandHistoryItems,
commandContext, commandContext,
@@ -1384,6 +1391,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
isSettingsDialogOpen, isSettingsDialogOpen,
isModelDialogOpen, isModelDialogOpen,
isPermissionsDialogOpen, isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands, slashCommands,
pendingSlashCommandHistoryItems, pendingSlashCommandHistoryItems,
commandContext, commandContext,
@@ -1474,6 +1482,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitPrivacyNotice, exitPrivacyNotice,
closeSettingsDialog, closeSettingsDialog,
closeModelDialog, closeModelDialog,
openPermissionsDialog,
closePermissionsDialog, closePermissionsDialog,
setShellModeActive, setShellModeActive,
vimHandleInput, vimHandleInput,
@@ -1502,6 +1511,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitPrivacyNotice, exitPrivacyNotice,
closeSettingsDialog, closeSettingsDialog,
closeModelDialog, closeModelDialog,
openPermissionsDialog,
closePermissionsDialog, closePermissionsDialog,
setShellModeActive, setShellModeActive,
vimHandleInput, vimHandleInput,
@@ -5,7 +5,8 @@
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js'; import { directoryCommand } from './directoryCommand.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import type { Config, WorkspaceContext } from '@google/gemini-cli-core'; import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
import type { CommandContext } from './types.js'; import type { CommandContext } from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
@@ -7,22 +7,8 @@
import type { SlashCommand, CommandContext } from './types.js'; import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core'; import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
import { expandHomeDir } from '../utils/directoryUtils.js';
export function expandHomeDir(p: string): string {
if (!p) {
return '';
}
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p === '~' || p.startsWith('~/')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
}
export const directoryCommand: SlashCommand = { export const directoryCommand: SlashCommand = {
name: 'directory', name: 'directory',
@@ -4,32 +4,113 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as process from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { permissionsCommand } from './permissionsCommand.js'; import { permissionsCommand } from './permissionsCommand.js';
import { type CommandContext, CommandKind } from './types.js'; import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('node:fs');
describe('permissionsCommand', () => { describe('permissionsCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
beforeEach(() => { beforeEach(() => {
mockContext = createMockCommandContext(); mockContext = createMockCommandContext();
vi.mocked(fs).statSync.mockReturnValue({
isDirectory: vi.fn(() => true),
} as unknown as fs.Stats);
});
afterEach(() => {
vi.restoreAllMocks();
}); });
it('should have the correct name and description', () => { it('should have the correct name and description', () => {
expect(permissionsCommand.name).toBe('permissions'); expect(permissionsCommand.name).toBe('permissions');
expect(permissionsCommand.description).toBe('Manage folder trust settings'); expect(permissionsCommand.description).toBe(
'Manage folder trust settings and other permissions',
);
}); });
it('should be a built-in command', () => { it('should be a built-in command', () => {
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN); expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
}); });
it('should return an action to open the permissions dialog', () => { it('should have a trust subcommand', () => {
const actionResult = permissionsCommand.action?.(mockContext, ''); const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
expect(trustCommand).toBeDefined();
expect(trustCommand?.name).toBe('trust');
expect(trustCommand?.description).toBe(
'Manage folder trust settings. Usage: /permissions trust [<directory-path>]',
);
expect(trustCommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the permissions dialog with a specified directory', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
const actionResult = trustCommand?.action?.(mockContext, '/test/dir');
expect(actionResult).toEqual({ expect(actionResult).toEqual({
type: 'dialog', type: 'dialog',
dialog: 'permissions', dialog: 'permissions',
props: {
targetDirectory: path.resolve('/test/dir'),
},
});
});
it('should return an action to open the permissions dialog with the current directory if no path is provided', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
const actionResult = trustCommand?.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'permissions',
props: {
targetDirectory: process.cwd(),
},
});
});
it('should return an error message if the provided path does not exist', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
vi.mocked(fs).statSync.mockImplementation(() => {
throw new Error('ENOENT: no such file or directory');
});
const actionResult = trustCommand?.action?.(
mockContext,
'/nonexistent/dir',
);
expect(actionResult).toEqual({
type: 'message',
messageType: 'error',
content: `Error accessing path: ${path.resolve(
'/nonexistent/dir',
)}. ENOENT: no such file or directory`,
});
});
it('should return an error message if the provided path is not a directory', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
vi.mocked(fs).statSync.mockReturnValue({
isDirectory: vi.fn(() => false),
} as unknown as fs.Stats);
const actionResult = trustCommand?.action?.(mockContext, '/file/not/dir');
expect(actionResult).toEqual({
type: 'message',
messageType: 'error',
content: `Path is not a directory: ${path.resolve('/file/not/dir')}`,
}); });
}); });
}); });
@@ -4,15 +4,80 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { OpenDialogActionReturn, SlashCommand } from './types.js'; import type {
OpenDialogActionReturn,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import * as process from 'node:process';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { expandHomeDir } from '../utils/directoryUtils.js';
export const permissionsCommand: SlashCommand = { export const permissionsCommand: SlashCommand = {
name: 'permissions', name: 'permissions',
description: 'Manage folder trust settings', description: 'Manage folder trust settings and other permissions',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({ subCommands: [
type: 'dialog', {
dialog: 'permissions', name: 'trust',
}), description:
'Manage folder trust settings. Usage: /permissions trust [<directory-path>]',
kind: CommandKind.BUILT_IN,
action: (context, input): SlashCommandActionReturn => {
const dirPath = input.trim();
let targetDirectory: string;
if (!dirPath) {
targetDirectory = process.cwd();
} else {
targetDirectory = path.resolve(expandHomeDir(dirPath));
}
try {
if (!fs.statSync(targetDirectory).isDirectory()) {
return {
type: 'message',
messageType: 'error',
content: `Path is not a directory: ${targetDirectory}`,
};
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return {
type: 'message',
messageType: 'error',
content: `Error accessing path: ${targetDirectory}. ${message}`,
};
}
return {
type: 'dialog',
dialog: 'permissions',
props: {
targetDirectory,
},
} as OpenDialogActionReturn;
},
},
],
action: (context, input): SlashCommandActionReturn => {
const parts = input.trim().split(' ');
const subcommand = parts[0];
if (!subcommand) {
return {
type: 'message',
messageType: 'error',
content: `Please provide a subcommand for /permissions. Usage: /permissions trust [<directory-path>]`,
};
}
return {
type: 'message',
messageType: 'error',
content: `Invalid subcommand for /permissions: ${subcommand}. Usage: /permissions trust [<directory-path>]`,
};
},
}; };
+1
View File
@@ -113,6 +113,7 @@ export interface MessageActionReturn {
*/ */
export interface OpenDialogActionReturn { export interface OpenDialogActionReturn {
type: 'dialog'; type: 'dialog';
props?: Record<string, unknown>;
dialog: dialog:
| 'help' | 'help'
@@ -205,6 +205,7 @@ export const DialogManager = ({
<PermissionsModifyTrustDialog <PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog} onExit={uiActions.closePermissionsDialog}
addItem={addItem} addItem={addItem}
targetDirectory={uiState.permissionsDialogProps?.targetDirectory}
/> />
); );
} }
@@ -16,7 +16,11 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { relaunchApp } from '../../utils/processUtils.js'; import { relaunchApp } from '../../utils/processUtils.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
interface PermissionsModifyTrustDialogProps { export interface PermissionsDialogProps {
targetDirectory?: string;
}
interface PermissionsModifyTrustDialogProps extends PermissionsDialogProps {
onExit: () => void; onExit: () => void;
addItem: UseHistoryManagerReturn['addItem']; addItem: UseHistoryManagerReturn['addItem'];
} }
@@ -24,9 +28,11 @@ interface PermissionsModifyTrustDialogProps {
export function PermissionsModifyTrustDialog({ export function PermissionsModifyTrustDialog({
onExit, onExit,
addItem, addItem,
targetDirectory,
}: PermissionsModifyTrustDialogProps): React.JSX.Element { }: PermissionsModifyTrustDialogProps): React.JSX.Element {
const dirName = path.basename(process.cwd()); const currentDirectory = targetDirectory ?? process.cwd();
const parentFolder = path.basename(path.dirname(process.cwd())); const dirName = path.basename(currentDirectory);
const parentFolder = path.basename(path.dirname(currentDirectory));
const TRUST_LEVEL_ITEMS = [ const TRUST_LEVEL_ITEMS = [
{ {
@@ -54,7 +60,7 @@ export function PermissionsModifyTrustDialog({
needsRestart, needsRestart,
updateTrustLevel, updateTrustLevel,
commitTrustLevelChange, commitTrustLevelChange,
} = usePermissionsModifyTrust(onExit, addItem); } = usePermissionsModifyTrust(onExit, addItem, currentDirectory);
useKeypress( useKeypress(
(key) => { (key) => {
@@ -11,6 +11,7 @@ import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { type AuthType, type EditorType } from '@google/gemini-cli-core'; import { type AuthType, type EditorType } from '@google/gemini-cli-core';
import { type LoadableSettingScope } from '../../config/settings.js'; import { type LoadableSettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js'; import type { AuthState } from '../types.js';
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
export interface UIActions { export interface UIActions {
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void; handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
@@ -30,6 +31,7 @@ export interface UIActions {
exitPrivacyNotice: () => void; exitPrivacyNotice: () => void;
closeSettingsDialog: () => void; closeSettingsDialog: () => void;
closeModelDialog: () => void; closeModelDialog: () => void;
openPermissionsDialog: (props?: PermissionsDialogProps) => void;
closePermissionsDialog: () => void; closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void; setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean; vimHandleInput: (key: Key) => boolean;
@@ -58,6 +58,7 @@ export interface UIState {
isSettingsDialogOpen: boolean; isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean; isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean; isPermissionsDialogOpen: boolean;
permissionsDialogProps: { targetDirectory?: string } | null;
slashCommands: readonly SlashCommand[] | undefined; slashCommands: readonly SlashCommand[] | undefined;
pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext; commandContext: CommandContext;
@@ -53,7 +53,7 @@ interface SlashCommandProcessorActions {
openPrivacyNotice: () => void; openPrivacyNotice: () => void;
openSettingsDialog: () => void; openSettingsDialog: () => void;
openModelDialog: () => void; openModelDialog: () => void;
openPermissionsDialog: () => void; openPermissionsDialog: (props?: { targetDirectory?: string }) => void;
quit: (messages: HistoryItem[]) => void; quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void; setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void; toggleCorgiMode: () => void;
@@ -405,7 +405,9 @@ export const useSlashCommandProcessor = (
actions.openModelDialog(); actions.openModelDialog();
return { type: 'handled' }; return { type: 'handled' };
case 'permissions': case 'permissions':
actions.openPermissionsDialog(); actions.openPermissionsDialog(
result.props as { targetDirectory?: string },
);
return { type: 'handled' }; return { type: 'handled' };
case 'help': case 'help':
return { type: 'handled' }; return { type: 'handled' };
@@ -32,6 +32,15 @@ vi.mock('node:process', () => ({
cwd: mockedCwd, cwd: mockedCwd,
})); }));
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual && typeof actual === 'object' ? actual : {}),
resolve: vi.fn((p) => p),
join: vi.fn((...args) => args.join('/')),
};
});
vi.mock('../../config/trustedFolders.js', () => ({ vi.mock('../../config/trustedFolders.js', () => ({
loadTrustedFolders: mockedLoadTrustedFolders, loadTrustedFolders: mockedLoadTrustedFolders,
isWorkspaceTrusted: mockedIsWorkspaceTrusted, isWorkspaceTrusted: mockedIsWorkspaceTrusted,
@@ -74,191 +83,262 @@ describe('usePermissionsModifyTrust', () => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
it('should initialize with the correct trust level', () => { describe('when targetDirectory is the current workspace', () => {
mockedLoadTrustedFolders.mockReturnValue({ it('should initialize with the correct trust level', () => {
user: { config: { '/test/dir': TrustLevel.TRUST_FOLDER } }, mockedLoadTrustedFolders.mockReturnValue({
} as unknown as LoadedTrustedFolders); user: { config: { '/test/dir': TrustLevel.TRUST_FOLDER } },
mockedIsWorkspaceTrusted.mockReturnValue({ } as unknown as LoadedTrustedFolders);
isTrusted: true, mockedIsWorkspaceTrusted.mockReturnValue({
source: 'file', isTrusted: true,
source: 'file',
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER);
}); });
const { result } = renderHook(() => it('should detect inherited trust from parent', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem), mockedLoadTrustedFolders.mockReturnValue({
); user: { config: {} },
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: true,
source: 'file',
});
expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER); const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
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, mockedCwd()),
);
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, mockedCwd()),
);
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, mockedCwd()),
);
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, mockedCwd()),
);
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, mockedCwd()),
);
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, mockedCwd()),
);
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),
);
});
}); });
it('should detect inherited trust from parent', () => { describe('when targetDirectory is not the current workspace', () => {
mockedLoadTrustedFolders.mockReturnValue({ const otherDirectory = '/other/dir';
user: { config: {} },
setValue: vi.fn(), it('should not detect inherited trust', () => {
} as unknown as LoadedTrustedFolders); mockedLoadTrustedFolders.mockReturnValue({
mockedIsWorkspaceTrusted.mockReturnValue({ user: { config: {} },
isTrusted: true, } as unknown as LoadedTrustedFolders);
source: 'file', mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: true,
source: 'file',
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
);
expect(result.current.isInheritedTrustFromParent).toBe(false);
expect(result.current.isInheritedTrustFromIde).toBe(false);
}); });
const { result } = renderHook(() => it('should save immediately without needing a restart', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem), const mockSetValue = vi.fn();
); mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: mockSetValue,
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: false,
source: 'file',
});
expect(result.current.isInheritedTrustFromParent).toBe(true); const { result } = renderHook(() =>
expect(result.current.isInheritedTrustFromIde).toBe(false); usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
}); );
it('should detect inherited trust from IDE', () => { act(() => {
mockedLoadTrustedFolders.mockReturnValue({ result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
user: { config: {} }, // No explicit trust });
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({ expect(result.current.needsRestart).toBe(false);
isTrusted: true, expect(mockSetValue).toHaveBeenCalledWith(
source: 'ide', otherDirectory,
TrustLevel.TRUST_FOLDER,
);
expect(mockOnExit).toHaveBeenCalled();
}); });
const { result } = renderHook(() => it('should not add a warning when setting DO_NOT_TRUST', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem), mockedLoadTrustedFolders.mockReturnValue({
); user: { config: {} },
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: true,
source: 'file',
});
expect(result.current.isInheritedTrustFromIde).toBe(true); const { result } = renderHook(() =>
expect(result.current.isInheritedTrustFromParent).toBe(false); usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
}); );
it('should set needsRestart but not save when trust changes', () => { act(() => {
const mockSetValue = vi.fn(); result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
mockedLoadTrustedFolders.mockReturnValue({ });
user: { config: {} },
setValue: mockSetValue,
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted expect(mockAddItem).not.toHaveBeenCalled();
.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),
);
}); });
it('should emit feedback when setValue throws in updateTrustLevel', () => { it('should emit feedback when setValue throws in updateTrustLevel', () => {
@@ -278,7 +358,7 @@ describe('usePermissionsModifyTrust', () => {
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const { result } = renderHook(() => const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { act(() => {
@@ -308,7 +388,7 @@ describe('usePermissionsModifyTrust', () => {
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const { result } = renderHook(() => const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { act(() => {
@@ -6,6 +6,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import * as process from 'node:process'; import * as process from 'node:process';
import * as path from 'node:path';
import { import {
loadTrustedFolders, loadTrustedFolders,
TrustLevel, TrustLevel,
@@ -27,9 +28,19 @@ interface TrustState {
function getInitialTrustState( function getInitialTrustState(
settings: LoadedSettings, settings: LoadedSettings,
cwd: string, cwd: string,
isCurrentWorkspace: boolean,
): TrustState { ): TrustState {
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
const explicitTrustLevel = folders.user.config[cwd]; const explicitTrustLevel = folders.user.config[cwd];
if (!isCurrentWorkspace) {
return {
currentTrustLevel: explicitTrustLevel,
isInheritedTrustFromParent: false,
isInheritedTrustFromIde: false,
};
}
const { isTrusted, source } = isWorkspaceTrusted(settings.merged); const { isTrusted, source } = isWorkspaceTrusted(settings.merged);
const isInheritedTrust = const isInheritedTrust =
@@ -46,11 +57,19 @@ function getInitialTrustState(
export const usePermissionsModifyTrust = ( export const usePermissionsModifyTrust = (
onExit: () => void, onExit: () => void,
addItem: UseHistoryManagerReturn['addItem'], addItem: UseHistoryManagerReturn['addItem'],
targetDirectory: string,
) => { ) => {
const settings = useSettings(); const settings = useSettings();
const cwd = process.cwd(); const cwd = targetDirectory;
// Normalize paths for case-insensitive file systems (macOS/Windows) to ensure
// accurate comparison between targetDirectory and process.cwd().
const isCurrentWorkspace =
path.resolve(targetDirectory).toLowerCase() ===
path.resolve(process.cwd()).toLowerCase();
const [initialState] = useState(() => getInitialTrustState(settings, cwd)); const [initialState] = useState(() =>
getInitialTrustState(settings, cwd, isCurrentWorkspace),
);
const [currentTrustLevel] = useState<TrustLevel | undefined>( const [currentTrustLevel] = useState<TrustLevel | undefined>(
initialState.currentTrustLevel, initialState.currentTrustLevel,
@@ -70,6 +89,16 @@ export const usePermissionsModifyTrust = (
const updateTrustLevel = useCallback( const updateTrustLevel = useCallback(
(trustLevel: TrustLevel) => { (trustLevel: TrustLevel) => {
// If we are not editing the current workspace, the logic is simple:
// just save the setting and exit. No restart or warnings are needed.
if (!isCurrentWorkspace) {
const folders = loadTrustedFolders();
folders.setValue(cwd, trustLevel);
onExit();
return;
}
// All logic below only applies when editing the current workspace.
const wasTrusted = isWorkspaceTrusted(settings.merged).isTrusted; const wasTrusted = isWorkspaceTrusted(settings.merged).isTrusted;
// Create a temporary config to check the new trust status without writing // Create a temporary config to check the new trust status without writing
@@ -113,7 +142,7 @@ export const usePermissionsModifyTrust = (
onExit(); onExit();
} }
}, },
[cwd, settings.merged, onExit, addItem], [cwd, settings.merged, onExit, addItem, isCurrentWorkspace],
); );
const commitTrustLevelChange = useCallback(() => { const commitTrustLevelChange = useCallback(() => {
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as os from 'node:os';
import * as path from 'node:path';
export function expandHomeDir(p: string): string {
if (!p) {
return '';
}
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p === '~' || p.startsWith('~/')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
}