feat: Show untrusted status in the Footer (#6210)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
shrutip90 2025-08-14 11:15:48 -07:00 committed by GitHub
parent 69d666cfaf
commit 69c5582723
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 221 additions and 60 deletions

View File

@ -1809,7 +1809,14 @@ describe('loadCliConfig trustedFolder', () => {
description,
} of testCases) {
it(`should be correct for: ${description}`, async () => {
(isWorkspaceTrusted as vi.Mock).mockReturnValue(mockTrustValue);
(isWorkspaceTrusted as vi.Mock).mockImplementation(
(settings: Settings) => {
const featureIsEnabled =
(settings.folderTrustFeature ?? false) &&
(settings.folderTrust ?? true);
return featureIsEnabled ? mockTrustValue : true;
},
);
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature, folderTrust };
const config = await loadCliConfig(settings, [], 'test-session', argv);

View File

@ -321,7 +321,7 @@ export async function loadCliConfig(
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? true;
const folderTrust = folderTrustFeature && folderTrustSetting;
const trustedFolder = folderTrust ? isWorkspaceTrusted() : true;
const trustedFolder = isWorkspaceTrusted(settings);
const allExtensions = annotateActiveExtensions(
extensions,

View File

@ -35,6 +35,7 @@ import {
TrustLevel,
isWorkspaceTrusted,
} from './trustedFolders.js';
import { Settings } from './settings.js';
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
@ -130,6 +131,10 @@ describe('Trusted Folders Loading', () => {
describe('isWorkspaceTrusted', () => {
let mockCwd: string;
const mockRules: Record<string, TrustLevel> = {};
const mockSettings: Settings = {
folderTrustFeature: true,
folderTrust: true,
};
beforeEach(() => {
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
@ -153,51 +158,51 @@ describe('isWorkspaceTrusted', () => {
it('should return true for a directly trusted folder', () => {
mockCwd = '/home/user/projectA';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
expect(isWorkspaceTrusted()).toBe(true);
expect(isWorkspaceTrusted(mockSettings)).toBe(true);
});
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()).toBe(true);
expect(isWorkspaceTrusted(mockSettings)).toBe(true);
});
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()).toBe(true);
expect(isWorkspaceTrusted(mockSettings)).toBe(true);
});
it('should return false for a directly untrusted folder', () => {
mockCwd = '/home/user/untrusted';
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted()).toBe(false);
expect(isWorkspaceTrusted(mockSettings)).toBe(false);
});
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()).toBeUndefined();
expect(isWorkspaceTrusted(mockSettings)).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()).toBeUndefined();
expect(isWorkspaceTrusted(mockSettings)).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()).toBe(true);
expect(isWorkspaceTrusted(mockSettings)).toBe(true);
});
it('should handle path normalization', () => {
mockCwd = '/home/user/projectA';
mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] =
TrustLevel.TRUST_FOLDER;
expect(isWorkspaceTrusted()).toBe(true);
expect(isWorkspaceTrusted(mockSettings)).toBe(true);
});
});

View File

@ -8,6 +8,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'os';
import { getErrorMessage, isWithinRoot } from '@google/gemini-cli-core';
import { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
@ -109,7 +110,15 @@ export function saveTrustedFolders(
}
}
export function isWorkspaceTrusted(): boolean | undefined {
export function isWorkspaceTrusted(settings: Settings): boolean | undefined {
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? true;
const folderTrustEnabled = folderTrustFeature && folderTrustSetting;
if (!folderTrustEnabled) {
return true;
}
const { rules, errors } = loadTrustedFolders();
if (errors.length > 0) {

View File

@ -163,6 +163,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getCurrentIde: vi.fn(() => 'vscode'),
getDetectedIdeDisplayName: vi.fn(() => 'VSCode'),
})),
isTrustedFolder: vi.fn(() => true),
};
});
@ -1118,5 +1119,45 @@ describe('App UI', () => {
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
it('should display the folder trust dialog when the feature is enabled but the folder is not trusted', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
});
mockConfig.isTrustedFolder.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
it('should not display the folder trust dialog when the feature is disabled', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
});
mockConfig.isTrustedFolder.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).not.toContain('Do you trust this folder?');
});
});
});

View File

@ -171,6 +171,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [editorError, setEditorError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false);
const [isTrustedFolderState, setIsTrustedFolder] = useState(
config.isTrustedFolder(),
);
const [currentModel, setCurrentModel] = useState(config.getModel());
const [shellModeActive, setShellModeActive] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
@ -254,7 +257,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
config,
setIsTrustedFolder,
);
const {
@ -1198,6 +1201,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
promptTokenCount={sessionStats.lastPromptTokenCount}
nightly={nightly}
vimMode={vimModeEnabled ? vimMode : undefined}
isTrustedFolder={isTrustedFolderState}
/>
</Box>
</Box>

View File

@ -103,4 +103,57 @@ describe('<Footer />', () => {
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
});
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: false,
});
expect(lastFrame()).toContain('untrusted');
});
it('should display custom sandbox info when SANDBOX env is set', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: undefined,
});
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
});
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
});
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
});
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
});
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
});
it('should prioritize untrusted message over sandbox info', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: false,
});
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
});
});
});

View File

@ -32,6 +32,7 @@ interface FooterProps {
promptTokenCount: number;
nightly: boolean;
vimMode?: string;
isTrustedFolder?: boolean;
}
export const Footer: React.FC<FooterProps> = ({
@ -47,6 +48,7 @@ export const Footer: React.FC<FooterProps> = ({
promptTokenCount,
nightly,
vimMode,
isTrustedFolder,
}) => {
const { columns: terminalWidth } = useTerminalSize();
@ -90,7 +92,7 @@ export const Footer: React.FC<FooterProps> = ({
)}
</Box>
{/* Middle Section: Centered Sandbox Info */}
{/* Middle Section: Centered Trust/Sandbox Info */}
<Box
flexGrow={isNarrow ? 0 : 1}
alignItems="center"
@ -99,7 +101,9 @@ export const Footer: React.FC<FooterProps> = ({
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
</Text>

View File

@ -7,7 +7,6 @@
import { vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFolderTrust } from './useFolderTrust.js';
import { type Config } from '@google/gemini-cli-core';
import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
@ -25,9 +24,10 @@ vi.mock('process', () => ({
describe('useFolderTrust', () => {
let mockSettings: LoadedSettings;
let mockConfig: Config;
let mockTrustedFolders: LoadedTrustedFolders;
let loadTrustedFoldersSpy: vi.SpyInstance;
let isWorkspaceTrustedSpy: vi.SpyInstance;
let onTrustChange: (isTrusted: boolean | undefined) => void;
beforeEach(() => {
mockSettings = {
@ -38,10 +38,6 @@ describe('useFolderTrust', () => {
setValue: vi.fn(),
} as unknown as LoadedSettings;
mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
mockTrustedFolders = {
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders;
@ -49,7 +45,9 @@ describe('useFolderTrust', () => {
loadTrustedFoldersSpy = vi
.spyOn(trustedFolders, 'loadTrustedFolders')
.mockReturnValue(mockTrustedFolders);
isWorkspaceTrustedSpy = vi.spyOn(trustedFolders, 'isWorkspaceTrusted');
(process.cwd as vi.Mock).mockReturnValue('/test/path');
onTrustChange = vi.fn();
});
afterEach(() => {
@ -57,34 +55,39 @@ describe('useFolderTrust', () => {
});
it('should not open dialog when folder is already trusted', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(true);
isWorkspaceTrustedSpy.mockReturnValue(true);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenCalledWith(true);
});
it('should not open dialog when folder is already untrusted', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(false);
isWorkspaceTrustedSpy.mockReturnValue(false);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenCalledWith(false);
});
it('should open dialog when folder trust is undefined', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(undefined);
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(onTrustChange).toHaveBeenCalledWith(undefined);
});
it('should handle TRUST_FOLDER choice', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
isWorkspaceTrustedSpy.mockReturnValue(true);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
@ -95,13 +98,16 @@ describe('useFolderTrust', () => {
TrustLevel.TRUST_FOLDER,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenLastCalledWith(true);
});
it('should handle TRUST_PARENT choice', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
isWorkspaceTrustedSpy.mockReturnValue(true);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT);
});
@ -111,13 +117,16 @@ describe('useFolderTrust', () => {
TrustLevel.TRUST_PARENT,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenLastCalledWith(true);
});
it('should handle DO_NOT_TRUST choice', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
isWorkspaceTrustedSpy.mockReturnValue(false);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST);
});
@ -127,11 +136,13 @@ describe('useFolderTrust', () => {
TrustLevel.DO_NOT_TRUST,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenLastCalledWith(false);
});
it('should do nothing for default choice', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
useFolderTrust(mockSettings, onTrustChange),
);
act(() => {
@ -143,5 +154,6 @@ describe('useFolderTrust', () => {
expect(mockTrustedFolders.setValue).not.toHaveBeenCalled();
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(onTrustChange).toHaveBeenCalledWith(undefined);
});
});

View File

@ -4,42 +4,68 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { type Config } from '@google/gemini-cli-core';
import { LoadedSettings } from '../../config/settings.js';
import { useState, useCallback, useEffect } from 'react';
import { Settings, LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
import {
loadTrustedFolders,
TrustLevel,
isWorkspaceTrusted,
} from '../../config/trustedFolders.js';
import * as process from 'process';
export const useFolderTrust = (settings: LoadedSettings, config: Config) => {
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
config.isTrustedFolder() === undefined,
export const useFolderTrust = (
settings: LoadedSettings,
onTrustChange: (isTrusted: boolean | undefined) => void,
) => {
const [isTrusted, setIsTrusted] = useState<boolean | undefined>(undefined);
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false);
const { folderTrust, folderTrustFeature } = settings.merged;
useEffect(() => {
const trusted = isWorkspaceTrusted({
folderTrust,
folderTrustFeature,
} as Settings);
setIsTrusted(trusted);
setIsFolderTrustDialogOpen(trusted === undefined);
onTrustChange(trusted);
}, [onTrustChange, folderTrust, folderTrustFeature]);
const handleFolderTrustSelect = useCallback(
(choice: FolderTrustChoice) => {
const trustedFolders = loadTrustedFolders();
const cwd = process.cwd();
let trustLevel: TrustLevel;
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
break;
case FolderTrustChoice.TRUST_PARENT:
trustLevel = TrustLevel.TRUST_PARENT;
break;
case FolderTrustChoice.DO_NOT_TRUST:
trustLevel = TrustLevel.DO_NOT_TRUST;
break;
default:
return;
}
trustedFolders.setValue(cwd, trustLevel);
const trusted = isWorkspaceTrusted({
folderTrust,
folderTrustFeature,
} as Settings);
setIsTrusted(trusted);
setIsFolderTrustDialogOpen(false);
onTrustChange(trusted);
},
[onTrustChange, folderTrust, folderTrustFeature],
);
const handleFolderTrustSelect = useCallback((choice: FolderTrustChoice) => {
const trustedFolders = loadTrustedFolders();
const cwd = process.cwd();
let trustLevel: TrustLevel;
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
break;
case FolderTrustChoice.TRUST_PARENT:
trustLevel = TrustLevel.TRUST_PARENT;
break;
case FolderTrustChoice.DO_NOT_TRUST:
trustLevel = TrustLevel.DO_NOT_TRUST;
break;
default:
return;
}
trustedFolders.setValue(cwd, trustLevel);
setIsFolderTrustDialogOpen(false);
}, []);
return {
isTrusted,
isFolderTrustDialogOpen,
handleFolderTrustSelect,
};