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

View File

@@ -196,14 +196,20 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const [permissionsDialogProps, setPermissionsDialogProps] = useState<{
targetDirectory?: string;
} | null>(null);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
[],
);
const closePermissionsDialog = useCallback(
() => setPermissionsDialogOpen(false),
(props?: { targetDirectory?: string }) => {
setPermissionsDialogOpen(true);
setPermissionsDialogProps(props ?? null);
},
[],
);
const closePermissionsDialog = useCallback(() => {
setPermissionsDialogOpen(false);
setPermissionsDialogProps(null);
}, []);
const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev),
@@ -1301,6 +1307,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1384,6 +1391,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1474,6 +1482,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@@ -1502,6 +1511,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View File

@@ -5,7 +5,8 @@
*/
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 { CommandContext } from './types.js';
import { MessageType } from '../types.js';

View File

@@ -7,22 +7,8 @@
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } 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';
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);
}
import { expandHomeDir } from '../utils/directoryUtils.js';
export const directoryCommand: SlashCommand = {
name: 'directory',

View File

@@ -4,32 +4,113 @@
* 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 { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('node:fs');
describe('permissionsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
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', () => {
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', () => {
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the permissions dialog', () => {
const actionResult = permissionsCommand.action?.(mockContext, '');
it('should have a trust subcommand', () => {
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({
type: 'dialog',
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')}`,
});
});
});

View File

@@ -4,15 +4,80 @@
* 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 * 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 = {
name: 'permissions',
description: 'Manage folder trust settings',
description: 'Manage folder trust settings and other permissions',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'permissions',
}),
subCommands: [
{
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>]`,
};
},
};

View File

@@ -113,6 +113,7 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
props?: Record<string, unknown>;
dialog:
| 'help'

View File

@@ -205,6 +205,7 @@ export const DialogManager = ({
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
targetDirectory={uiState.permissionsDialogProps?.targetDirectory}
/>
);
}

View File

@@ -16,7 +16,11 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
interface PermissionsModifyTrustDialogProps {
export interface PermissionsDialogProps {
targetDirectory?: string;
}
interface PermissionsModifyTrustDialogProps extends PermissionsDialogProps {
onExit: () => void;
addItem: UseHistoryManagerReturn['addItem'];
}
@@ -24,9 +28,11 @@ interface PermissionsModifyTrustDialogProps {
export function PermissionsModifyTrustDialog({
onExit,
addItem,
targetDirectory,
}: PermissionsModifyTrustDialogProps): React.JSX.Element {
const dirName = path.basename(process.cwd());
const parentFolder = path.basename(path.dirname(process.cwd()));
const currentDirectory = targetDirectory ?? process.cwd();
const dirName = path.basename(currentDirectory);
const parentFolder = path.basename(path.dirname(currentDirectory));
const TRUST_LEVEL_ITEMS = [
{
@@ -54,7 +60,7 @@ export function PermissionsModifyTrustDialog({
needsRestart,
updateTrustLevel,
commitTrustLevelChange,
} = usePermissionsModifyTrust(onExit, addItem);
} = usePermissionsModifyTrust(onExit, addItem, currentDirectory);
useKeypress(
(key) => {

View File

@@ -11,6 +11,7 @@ import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { type AuthType, type EditorType } from '@google/gemini-cli-core';
import { type LoadableSettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
export interface UIActions {
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
@@ -30,6 +31,7 @@ export interface UIActions {
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
openPermissionsDialog: (props?: PermissionsDialogProps) => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;

View File

@@ -58,6 +58,7 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
permissionsDialogProps: { targetDirectory?: string } | null;
slashCommands: readonly SlashCommand[] | undefined;
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;

View File

@@ -53,7 +53,7 @@ interface SlashCommandProcessorActions {
openPrivacyNotice: () => void;
openSettingsDialog: () => void;
openModelDialog: () => void;
openPermissionsDialog: () => void;
openPermissionsDialog: (props?: { targetDirectory?: string }) => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
@@ -405,7 +405,9 @@ export const useSlashCommandProcessor = (
actions.openModelDialog();
return { type: 'handled' };
case 'permissions':
actions.openPermissionsDialog();
actions.openPermissionsDialog(
result.props as { targetDirectory?: string },
);
return { type: 'handled' };
case 'help':
return { type: 'handled' };

View File

@@ -32,6 +32,15 @@ vi.mock('node:process', () => ({
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', () => ({
loadTrustedFolders: mockedLoadTrustedFolders,
isWorkspaceTrusted: mockedIsWorkspaceTrusted,
@@ -74,191 +83,262 @@ describe('usePermissionsModifyTrust', () => {
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',
describe('when targetDirectory is the current workspace', () => {
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, mockedCwd()),
);
expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER);
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
);
it('should detect inherited trust from parent', () => {
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', () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: true,
source: 'file',
describe('when targetDirectory is not the current workspace', () => {
const otherDirectory = '/other/dir';
it('should not detect inherited trust', () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
} as unknown as LoadedTrustedFolders);
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(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
);
it('should save immediately without needing a restart', () => {
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);
expect(result.current.isInheritedTrustFromIde).toBe(false);
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
);
it('should detect inherited trust from IDE', () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, // No explicit trust
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: true,
source: 'ide',
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
});
expect(result.current.needsRestart).toBe(false);
expect(mockSetValue).toHaveBeenCalledWith(
otherDirectory,
TrustLevel.TRUST_FOLDER,
);
expect(mockOnExit).toHaveBeenCalled();
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
);
it('should not add a warning when setting DO_NOT_TRUST', () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders);
mockedIsWorkspaceTrusted.mockReturnValue({
isTrusted: true,
source: 'file',
});
expect(result.current.isInheritedTrustFromIde).toBe(true);
expect(result.current.isInheritedTrustFromParent).toBe(false);
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
);
it('should set needsRestart but not save when trust changes', () => {
const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: mockSetValue,
} as unknown as LoadedTrustedFolders);
act(() => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
});
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(mockAddItem).not.toHaveBeenCalled();
});
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', () => {
@@ -278,7 +358,7 @@ describe('usePermissionsModifyTrust', () => {
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
@@ -308,7 +388,7 @@ describe('usePermissionsModifyTrust', () => {
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {

View File

@@ -6,6 +6,7 @@
import { useState, useCallback } from 'react';
import * as process from 'node:process';
import * as path from 'node:path';
import {
loadTrustedFolders,
TrustLevel,
@@ -27,9 +28,19 @@ interface TrustState {
function getInitialTrustState(
settings: LoadedSettings,
cwd: string,
isCurrentWorkspace: boolean,
): TrustState {
const folders = loadTrustedFolders();
const explicitTrustLevel = folders.user.config[cwd];
if (!isCurrentWorkspace) {
return {
currentTrustLevel: explicitTrustLevel,
isInheritedTrustFromParent: false,
isInheritedTrustFromIde: false,
};
}
const { isTrusted, source } = isWorkspaceTrusted(settings.merged);
const isInheritedTrust =
@@ -46,11 +57,19 @@ function getInitialTrustState(
export const usePermissionsModifyTrust = (
onExit: () => void,
addItem: UseHistoryManagerReturn['addItem'],
targetDirectory: string,
) => {
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>(
initialState.currentTrustLevel,
@@ -70,6 +89,16 @@ export const usePermissionsModifyTrust = (
const updateTrustLevel = useCallback(
(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;
// Create a temporary config to check the new trust status without writing
@@ -113,7 +142,7 @@ export const usePermissionsModifyTrust = (
onExit();
}
},
[cwd, settings.merged, onExit, addItem],
[cwd, settings.merged, onExit, addItem, isCurrentWorkspace],
);
const commitTrustLevelChange = useCallback(() => {

View File

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