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

@@ -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(() => {