From 6b604793f3d360a5c5bafde72e7c0ec684da26ba Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 18 Feb 2026 15:37:15 -0800 Subject: [PATCH 1/6] feat(cli): enhance folder trust with configuration discovery and security warnings Implement FolderTrustDiscoveryService to safely scan untrusted folders for local configurations and security-sensitive settings. Update the trust dialog to display these findings, providing users with transparency into what will be loaded before they grant trust. - Add FolderTrustDiscoveryService for read-only .gemini directory scanning - Detect commands, skills, MCP servers, hooks, and setting overrides - Flag security risks such as disabled sandboxes or auto-approved tools - Update FolderTrustDialog to display discovery results and warnings - Integrate discovery into useFolderTrust hook for real-time scanning - Ensure dialog remains usable in small terminals with scrolling/truncation --- .../FolderTrustDiscoveryService.test.ts | 130 +++++++++ .../services/FolderTrustDiscoveryService.ts | 204 ++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 10 +- .../cli/src/ui/components/DialogManager.tsx | 1 + .../ui/components/FolderTrustDialog.test.tsx | 259 +++++++++++++++++- .../src/ui/components/FolderTrustDialog.tsx | 245 +++++++++++++++-- .../src/ui/components/shared/MaxSizedBox.tsx | 10 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../cli/src/ui/hooks/useFolderTrust.test.ts | 6 + packages/cli/src/ui/hooks/useFolderTrust.ts | 22 ++ 10 files changed, 853 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/services/FolderTrustDiscoveryService.test.ts create mode 100644 packages/cli/src/services/FolderTrustDiscoveryService.ts diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.test.ts b/packages/cli/src/services/FolderTrustDiscoveryService.test.ts new file mode 100644 index 00000000000..4de6913f9b1 --- /dev/null +++ b/packages/cli/src/services/FolderTrustDiscoveryService.test.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { FolderTrustDiscoveryService } from './FolderTrustDiscoveryService.js'; +import { GEMINI_DIR } from '@google/gemini-cli-core'; + +describe('FolderTrustDiscoveryService', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-discovery-test-'), + ); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should discover commands, skills, mcps, and hooks', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + // Mock commands + const commandsDir = path.join(geminiDir, 'commands'); + await fs.mkdir(commandsDir); + await fs.writeFile( + path.join(commandsDir, 'test-cmd.toml'), + 'prompt = "test"', + ); + + // Mock skills + const skillsDir = path.join(geminiDir, 'skills'); + await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body'); + + // Mock settings (MCPs, Hooks, and general settings) + const settings = { + mcpServers: { + 'test-mcp': { command: 'node', args: ['test.js'] }, + }, + hooks: { + BeforeTool: [{ command: 'test-hook' }], + }, + general: { vimMode: true }, + ui: { theme: 'Dark' }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.commands).toContain('test-cmd'); + expect(results.skills).toContain('test-skill'); + expect(results.mcps).toContain('test-mcp'); + expect(results.hooks).toContain('test-hook'); + expect(results.settings).toContain('general'); + expect(results.settings).toContain('ui'); + expect(results.settings).not.toContain('mcpServers'); + expect(results.settings).not.toContain('hooks'); + }); + + it('should flag security warnings for sensitive settings', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + const settings = { + tools: { + allowed: ['git'], + sandbox: false, + }, + experimental: { + enableAgents: true, + }, + security: { + folderTrust: { + enabled: false, + }, + }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.securityWarnings).toContain( + 'This project auto-approves certain tools (tools.allowed).', + ); + expect(results.securityWarnings).toContain( + 'This project enables autonomous agents (enableAgents).', + ); + expect(results.securityWarnings).toContain( + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + ); + expect(results.securityWarnings).toContain( + 'This project disables the security sandbox (tools.sandbox).', + ); + }); + + it('should handle missing .gemini directory', async () => { + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.commands).toHaveLength(0); + expect(results.skills).toHaveLength(0); + expect(results.mcps).toHaveLength(0); + expect(results.hooks).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle malformed settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'invalid json'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.mcps).toHaveLength(0); + expect(results.hooks).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.ts b/packages/cli/src/services/FolderTrustDiscoveryService.ts new file mode 100644 index 00000000000..f18568a33aa --- /dev/null +++ b/packages/cli/src/services/FolderTrustDiscoveryService.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import stripJsonComments from 'strip-json-comments'; +import { GEMINI_DIR } from '@google/gemini-cli-core'; + +export interface FolderDiscoveryResults { + commands: string[]; + mcps: string[]; + hooks: string[]; + skills: string[]; + settings: string[]; + securityWarnings: string[]; + discoveryErrors: string[]; +} + +/** + * A safe, read-only service to discover local configurations in a folder + * before it is trusted. + */ +export class FolderTrustDiscoveryService { + /** + * Discovers configurations in the given workspace directory. + * @param workspaceDir The directory to scan. + * @returns A summary of discovered configurations. + */ + static async discover(workspaceDir: string): Promise { + const results: FolderDiscoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + securityWarnings: [], + discoveryErrors: [], + }; + + const geminiDir = path.join(workspaceDir, GEMINI_DIR); + if (!existsSync(geminiDir)) { + return results; + } + + await Promise.all([ + this.discoverCommands(geminiDir, results), + this.discoverSkills(geminiDir, results), + this.discoverSettings(geminiDir, results), + ]); + + return results; + } + + private static async discoverCommands( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const commandsDir = path.join(geminiDir, 'commands'); + if (existsSync(commandsDir)) { + try { + const files = await fs.readdir(commandsDir, { recursive: true }); + results.commands = files + .filter((f) => f.endsWith('.toml')) + .map((f) => path.basename(f, '.toml')); + } catch (e) { + results.discoveryErrors.push( + `Failed to discover commands: ${(e as Error).message}`, + ); + } + } + } + + private static async discoverSkills( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const skillsDir = path.join(geminiDir, 'skills'); + if (existsSync(skillsDir)) { + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); + if (existsSync(skillMdPath)) { + results.skills.push(entry.name); + } + } + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover skills: ${(e as Error).message}`, + ); + } + } + } + + private static async discoverSettings( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const settingsPath = path.join(geminiDir, 'settings.json'); + if (existsSync(settingsPath)) { + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(stripJsonComments(content)) as Record< + string, + unknown + >; + + const EXCLUDED_KEYS = ['mcpServers', 'hooks', '$schema']; + results.settings = Object.keys(settings).filter( + (key) => !EXCLUDED_KEYS.includes(key), + ); + + results.securityWarnings = this.collectSecurityWarnings(settings); + + if ( + settings['mcpServers'] && + typeof settings['mcpServers'] === 'object' && + !Array.isArray(settings['mcpServers']) + ) { + results.mcps = Object.keys(settings['mcpServers']); + } + + const hooksConfig = settings['hooks']; + if ( + hooksConfig && + typeof hooksConfig === 'object' && + !Array.isArray(hooksConfig) + ) { + const hooks = new Set(); + for (const event of Object.values(hooksConfig)) { + if (Array.isArray(event)) { + for (const hook of event) { + if ( + hook && + typeof hook === 'object' && + 'command' in hook && + typeof hook.command === 'string' + ) { + hooks.add(hook.command); + } + } + } + } + results.hooks = Array.from(hooks); + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover settings: ${(e as Error).message}`, + ); + } + } + } + + private static collectSecurityWarnings( + settings: Record, + ): string[] { + const warnings: string[] = []; + + // 1. tools.allowed + const tools = settings['tools'] as Record | undefined; + const toolsAllowed = tools?.['allowed']; + if (Array.isArray(toolsAllowed) && toolsAllowed.length > 0) { + warnings.push( + 'This project auto-approves certain tools (tools.allowed).', + ); + } + + // 2. experimental.enableAgents + const experimental = settings['experimental'] as + | Record + | undefined; + if (experimental?.['enableAgents'] === true) { + warnings.push('This project enables autonomous agents (enableAgents).'); + } + + // 3. security.folderTrust.enabled + const security = settings['security'] as + | Record + | undefined; + const folderTrust = security?.['folderTrust'] as + | Record + | undefined; + if (folderTrust?.['enabled'] === false) { + warnings.push( + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + ); + } + + // 4. tools.sandbox + if (tools?.['sandbox'] === false) { + warnings.push( + 'This project disables the security sandbox (tools.sandbox).', + ); + } + + return warnings; + } +} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index efae760cc13..7b78e688495 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1268,8 +1268,12 @@ Logging in with Google... Restarting Gemini CLI to continue. const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [warningMessage, setWarningMessage] = useState(null); - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = - useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + const { + isFolderTrustDialogOpen, + discoveryResults: folderDiscoveryResults, + handleFolderTrustSelect, + isRestarting, + } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -1847,6 +1851,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + folderDiscoveryResults, isTrustedFolder, constrainHeight, showErrorDetails, @@ -1952,6 +1957,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + folderDiscoveryResults, isTrustedFolder, constrainHeight, showErrorDetails, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 6d4db7ca3b1..438c562d57d 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -106,6 +106,7 @@ export const DialogManager = ({ ); } diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 0597a8167b8..771056d6a8f 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({ const mockedExit = vi.hoisted(() => vi.fn()); const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedRows = vi.hoisted(() => ({ current: 24 })); vi.mock('node:process', async () => { const actual = @@ -29,11 +30,16 @@ vi.mock('node:process', async () => { }; }); +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }), +})); + describe('FolderTrustDialog', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); mockedCwd.mockReturnValue('/home/user/project'); + mockedRows.current = 24; }); it('should render the dialog with title and description', () => { @@ -41,13 +47,154 @@ describe('FolderTrustDialog', () => { , ); - expect(lastFrame()).toContain('Do you trust this folder?'); + expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( - 'Trusting a folder allows Gemini to execute commands it suggests.', + 'Trusting a folder allows Gemini CLI to load its local configurations', + ); + unmount(); + }); + + it('should truncate discovery results when they exceed maxDiscoveryHeight', () => { + // maxDiscoveryHeight = 24 - 15 = 9. + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), + hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), + skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), + settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should adjust maxHeight based on terminal rows', () => { + mockedRows.current = 14; // maxHeight = 14 - 10 = 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 14 }, + }, + ); + + // With maxHeight=4, the intro text (4 lines) will take most of the space. + // The discovery results will likely be hidden. + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should use minimum maxHeight of 4', () => { + mockedRows.current = 8; // 8 - 10 = -2, should use 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 10 }, + }, ); + + expect(lastFrame()).toContain('hidden'); unmount(); }); + it('should toggle expansion when global Ctrl+O is handled', async () => { + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + // Initially constrained + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + // Initial state: truncated + await waitFor(() => { + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + expect(lastFrame()).toContain('hidden'); + }); + + // We can't easily simulate global Ctrl+O toggle in this unit test + // because it's handled in AppContainer. + // But we can re-render with constrainHeight: false. + const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = + renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: false, terminalHeight: 24 }, + }, + ); + + await waitFor(() => { + expect(lastFrameExpanded()).not.toContain('hidden'); + expect(lastFrameExpanded()).toContain('- cmd9'); + expect(lastFrameExpanded()).toContain('- cmd4'); + }); + + unmount(); + unmountExpanded(); + }); + it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); const { lastFrame, stdin, unmount } = renderWithProviders( @@ -150,5 +297,113 @@ describe('FolderTrustDialog', () => { expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); + + it('should display discovery results when provided', () => { + mockedRows.current = 40; // Increase height to show all results + const discoveryResults = { + commands: ['cmd1', 'cmd2'], + mcps: ['mcp1'], + hooks: ['hook1'], + skills: ['skill1'], + settings: ['general', 'ui'], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount } = renderWithProviders( + , + { width: 80 }, + ); + + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('• Commands (2):'); + expect(lastFrame()).toContain('- cmd1'); + expect(lastFrame()).toContain('- cmd2'); + expect(lastFrame()).toContain('• MCP Servers (1):'); + expect(lastFrame()).toContain('- mcp1'); + expect(lastFrame()).toContain('• Hooks (1):'); + expect(lastFrame()).toContain('- hook1'); + expect(lastFrame()).toContain('• Skills (1):'); + expect(lastFrame()).toContain('- skill1'); + expect(lastFrame()).toContain('• Setting overrides (2):'); + expect(lastFrame()).toContain('- general'); + expect(lastFrame()).toContain('- ui'); + unmount(); + }); + + it('should display security warnings when provided', () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: ['Dangerous setting detected!'], + }; + const { lastFrame, unmount } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Security Warnings:'); + expect(lastFrame()).toContain('Dangerous setting detected!'); + unmount(); + }); + + it('should display discovery errors when provided', () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: ['Failed to load custom commands'], + securityWarnings: [], + }; + const { lastFrame, unmount } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Discovery Errors:'); + expect(lastFrame()).toContain('Failed to load custom commands'); + unmount(); + }); + + it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', () => { + const discoveryResults = { + commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: true, + uiState: { constrainHeight: false, terminalHeight: 15 }, + }, + ); + + // In alternate buffer + expanded, the title should be visible (StickyHeader) + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + // And it should NOT use MaxSizedBox truncation + expect(lastFrame()).not.toContain('hidden'); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 9886e3b5e48..615fb8a85f7 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -10,12 +10,20 @@ import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { Scrollable } from './shared/Scrollable.js'; import { useKeypress } from '../hooks/useKeypress.js'; import * as process from 'node:process'; import * as path from 'node:path'; import { relaunchApp } from '../../utils/processUtils.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { ExitCodes } from '@google/gemini-cli-core'; +import type { FolderDiscoveryResults } from '../../services/FolderTrustDiscoveryService.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../contexts/OverflowContext.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { StickyHeader } from './StickyHeader.js'; export enum FolderTrustChoice { TRUST_FOLDER = 'trust_folder', @@ -26,13 +34,19 @@ export enum FolderTrustChoice { interface FolderTrustDialogProps { onSelect: (choice: FolderTrustChoice) => void; isRestarting?: boolean; + discoveryResults?: FolderDiscoveryResults | null; } export const FolderTrustDialog: React.FC = ({ onSelect, isRestarting, + discoveryResults, }) => { const [exiting, setExiting] = useState(false); + const { terminalHeight, terminalWidth, constrainHeight } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); + + const isExpanded = !constrainHeight; useEffect(() => { let timer: ReturnType; @@ -87,48 +101,219 @@ export const FolderTrustDialog: React.FC = ({ }, ]; - return ( - + const hasDiscovery = + discoveryResults && + (discoveryResults.commands.length > 0 || + discoveryResults.mcps.length > 0 || + discoveryResults.hooks.length > 0 || + discoveryResults.skills.length > 0 || + discoveryResults.settings.length > 0); + + const hasWarnings = + discoveryResults && discoveryResults.securityWarnings.length > 0; + + const hasErrors = + discoveryResults && + discoveryResults.discoveryErrors && + discoveryResults.discoveryErrors.length > 0; + + const dialogWidth = terminalWidth - 2; + const borderColor = theme.status.warning; + + // Header: 3 lines + // Options: options.length + 2 lines for margins + // Footer: 1 line + // Safety margin: 2 lines + const overhead = 3 + options.length + 2 + 1 + 2; + const scrollableHeight = Math.max(4, terminalHeight - overhead); + + const discoveryContent = ( + + + + Trusting a folder allows Gemini CLI to load its local configurations, + including custom commands, hooks, MCP servers, agent skills, and + settings. These configurations could execute code on your behalf or + change the behavior of the CLI. + + + + {hasErrors && ( + + + ❌ Discovery Errors: + + {discoveryResults.discoveryErrors.map((error, index) => ( + + • {error} + + ))} + + )} + + {hasWarnings && ( + + + ⚠️ Security Warnings: + + {discoveryResults.securityWarnings.map((warning, index) => ( + + • {warning} + + ))} + + )} + + {hasDiscovery && ( + + + This folder contains: + + {[ + { label: 'Commands', items: discoveryResults.commands }, + { label: 'MCP Servers', items: discoveryResults.mcps }, + { label: 'Hooks', items: discoveryResults.hooks }, + { label: 'Skills', items: discoveryResults.skills }, + { + label: 'Setting overrides', + items: discoveryResults.settings, + }, + ] + .filter((group) => group.items.length > 0) + .map((group) => ( + + + • {group.label} ({group.items.length}): + + {group.items.map((item, idx) => ( + + - {item} + + ))} + + ))} + + )} + + ); + + const renderContent = () => { + if (isAlternateBuffer) { + return ( + + + + Do you trust the files in this folder? + + + + + + + {discoveryContent} + + + + + + + + + + + ); + } + + return ( - Do you trust this folder? - - - Trusting a folder allows Gemini to execute commands it suggests. - This is a security feature to prevent accidental execution in - untrusted directories. + Do you trust the files in this folder? - + + {discoveryContent} + + + + + - {isRestarting && ( - - - Gemini CLI is restarting to apply the trust changes... - + ); + }; + + return ( + + + + {renderContent()} - )} - {exiting && ( - - - A folder trust level must be selected to continue. Exiting since - escape was pressed. - + + + - )} - + + {isRestarting && ( + + + Gemini CLI is restarting to apply the trust changes... + + + )} + {exiting && ( + + + A folder trust level must be selected to continue. Exiting since + escape was pressed. + + + )} + + ); }; diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 85ad4509ff6..3ce1fc47584 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -23,6 +23,7 @@ interface MaxSizedBoxProps { maxHeight?: number; overflowDirection?: 'top' | 'bottom'; additionalHiddenLinesCount?: number; + onOverflowChange?: (isOverflowing: boolean) => void; } /** @@ -35,6 +36,7 @@ export const MaxSizedBox: React.FC = ({ maxHeight, overflowDirection = 'top', additionalHiddenLinesCount = 0, + onOverflowChange, }) => { const id = useId(); const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; @@ -71,6 +73,10 @@ export const MaxSizedBox: React.FC = ({ (effectiveMaxHeight !== undefined && contentHeight > effectiveMaxHeight) || additionalHiddenLinesCount > 0; + useEffect(() => { + onOverflowChange?.(isOverflowing); + }, [isOverflowing, onOverflowChange]); + // If we're overflowing, we need to hide at least 1 line for the message. const visibleContentHeight = isOverflowing && effectiveMaxHeight !== undefined @@ -115,7 +121,7 @@ export const MaxSizedBox: React.FC = ({ flexShrink={0} > {totalHiddenLines > 0 && overflowDirection === 'top' && ( - + ... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '} hidden ... @@ -136,7 +142,7 @@ export const MaxSizedBox: React.FC = ({ {totalHiddenLines > 0 && overflowDirection === 'bottom' && ( - + ... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '} hidden ... diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 5ba697c85db..af637ca4c39 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -51,6 +51,7 @@ import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; import type { BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import type { FolderDiscoveryResults } from '../../services/FolderTrustDiscoveryService.js'; export interface UIState { history: HistoryItem[]; @@ -99,6 +100,7 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + folderDiscoveryResults: FolderDiscoveryResults | null; isTrustedFolder: boolean | undefined; constrainHeight: boolean; showErrorDetails: boolean; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 1e56b6d39e9..407ec3ea3ac 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -25,6 +25,12 @@ import { TrustLevel } from '../../config/trustedFolders.js'; import * as trustedFolders from '../../config/trustedFolders.js'; import { coreEvents, ExitCodes } from '@google/gemini-cli-core'; +vi.mock('../../services/FolderTrustDiscoveryService.js', () => ({ + FolderTrustDiscoveryService: { + discover: vi.fn(() => new Promise(() => {})), + }, +})); + const mockedCwd = vi.hoisted(() => vi.fn()); const mockedExit = vi.hoisted(() => vi.fn()); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index c3e3d6e70ca..a56e457f816 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -16,6 +16,10 @@ import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; import { coreEvents, ExitCodes } from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; +import { + FolderTrustDiscoveryService, + type FolderDiscoveryResults, +} from '../../services/FolderTrustDiscoveryService.js'; export const useFolderTrust = ( settings: LoadedSettings, @@ -24,6 +28,8 @@ export const useFolderTrust = ( ) => { const [isTrusted, setIsTrusted] = useState(undefined); const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false); + const [discoveryResults, setDiscoveryResults] = + useState(null); const [isRestarting, setIsRestarting] = useState(false); const startupMessageSent = useRef(false); @@ -35,6 +41,17 @@ export const useFolderTrust = ( setIsFolderTrustDialogOpen(trusted === undefined); onTrustChange(trusted); + let isMounted = true; + if (trusted === undefined || trusted === false) { + void FolderTrustDiscoveryService.discover(process.cwd()).then( + (results) => { + if (isMounted) { + setDiscoveryResults(results); + } + }, + ); + } + if (trusted === false && !startupMessageSent.current) { addItem( { @@ -45,6 +62,10 @@ export const useFolderTrust = ( ); startupMessageSent.current = true; } + + return () => { + isMounted = false; + }; }, [folderTrust, onTrustChange, settings.merged, addItem]); const handleFolderTrustSelect = useCallback( @@ -99,6 +120,7 @@ export const useFolderTrust = ( return { isTrusted, isFolderTrustDialogOpen, + discoveryResults, handleFolderTrustSelect, isRestarting, }; From 1aad6087c2a742b4f8e6fc16156470f556e03a68 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 18 Feb 2026 16:44:14 -0800 Subject: [PATCH 2/6] refactor folderTrustDialog --- .gemini/settings.json | 7 - .../services/FolderTrustDiscoveryService.ts | 135 +++++++++--------- .../src/ui/components/FolderTrustDialog.tsx | 93 ++++++------ packages/cli/src/ui/hooks/useFolderTrust.ts | 2 - 4 files changed, 111 insertions(+), 126 deletions(-) delete mode 100644 .gemini/settings.json diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index f84c17e60a1..00000000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "experimental": { - "toolOutputMasking": { - "enabled": true - } - } -} diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.ts b/packages/cli/src/services/FolderTrustDiscoveryService.ts index f18568a33aa..8c7e2a4c30c 100644 --- a/packages/cli/src/services/FolderTrustDiscoveryService.ts +++ b/packages/cli/src/services/FolderTrustDiscoveryService.ts @@ -103,57 +103,56 @@ export class FolderTrustDiscoveryService { results: FolderDiscoveryResults, ) { const settingsPath = path.join(geminiDir, 'settings.json'); - if (existsSync(settingsPath)) { - try { - const content = await fs.readFile(settingsPath, 'utf-8'); - const settings = JSON.parse(stripJsonComments(content)) as Record< - string, - unknown - >; - - const EXCLUDED_KEYS = ['mcpServers', 'hooks', '$schema']; - results.settings = Object.keys(settings).filter( - (key) => !EXCLUDED_KEYS.includes(key), - ); + if (!existsSync(settingsPath)) return; - results.securityWarnings = this.collectSecurityWarnings(settings); + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(stripJsonComments(content)) as Record< + string, + unknown + >; - if ( - settings['mcpServers'] && - typeof settings['mcpServers'] === 'object' && - !Array.isArray(settings['mcpServers']) - ) { - results.mcps = Object.keys(settings['mcpServers']); - } + results.settings = Object.keys(settings).filter( + (key) => !['mcpServers', 'hooks', '$schema'].includes(key), + ); + + results.securityWarnings = this.collectSecurityWarnings(settings); - const hooksConfig = settings['hooks']; - if ( - hooksConfig && - typeof hooksConfig === 'object' && - !Array.isArray(hooksConfig) - ) { - const hooks = new Set(); - for (const event of Object.values(hooksConfig)) { - if (Array.isArray(event)) { - for (const hook of event) { - if ( - hook && - typeof hook === 'object' && - 'command' in hook && - typeof hook.command === 'string' - ) { - hooks.add(hook.command); - } - } + const mcpServers = settings['mcpServers']; + if ( + mcpServers && + typeof mcpServers === 'object' && + !Array.isArray(mcpServers) + ) { + results.mcps = Object.keys(mcpServers); + } + + const hooksConfig = settings['hooks']; + if ( + hooksConfig && + typeof hooksConfig === 'object' && + !Array.isArray(hooksConfig) + ) { + const hooks = new Set(); + for (const event of Object.values(hooksConfig)) { + if (!Array.isArray(event)) continue; + for (const hook of event) { + if ( + hook && + typeof hook === 'object' && + 'command' in hook && + typeof (hook as Record)['command'] === 'string' + ) { + hooks.add((hook as Record)['command']); } } - results.hooks = Array.from(hooks); } - } catch (e) { - results.discoveryErrors.push( - `Failed to discover settings: ${(e as Error).message}`, - ); + results.hooks = Array.from(hooks); } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover settings: ${(e as Error).message}`, + ); } } @@ -162,41 +161,41 @@ export class FolderTrustDiscoveryService { ): string[] { const warnings: string[] = []; - // 1. tools.allowed const tools = settings['tools'] as Record | undefined; - const toolsAllowed = tools?.['allowed']; - if (Array.isArray(toolsAllowed) && toolsAllowed.length > 0) { - warnings.push( - 'This project auto-approves certain tools (tools.allowed).', - ); - } - - // 2. experimental.enableAgents const experimental = settings['experimental'] as | Record | undefined; - if (experimental?.['enableAgents'] === true) { - warnings.push('This project enables autonomous agents (enableAgents).'); - } - - // 3. security.folderTrust.enabled const security = settings['security'] as | Record | undefined; const folderTrust = security?.['folderTrust'] as | Record | undefined; - if (folderTrust?.['enabled'] === false) { - warnings.push( - 'This project attempts to disable folder trust (security.folderTrust.enabled).', - ); - } - // 4. tools.sandbox - if (tools?.['sandbox'] === false) { - warnings.push( - 'This project disables the security sandbox (tools.sandbox).', - ); + const allowedTools = tools?.['allowed']; + + const checks = [ + { + condition: Array.isArray(allowedTools) && allowedTools.length > 0, + message: 'This project auto-approves certain tools (tools.allowed).', + }, + { + condition: experimental?.['enableAgents'] === true, + message: 'This project enables autonomous agents (enableAgents).', + }, + { + condition: folderTrust?.['enabled'] === false, + message: + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + }, + { + condition: tools?.['sandbox'] === false, + message: 'This project disables the security sandbox (tools.sandbox).', + }, + ]; + + for (const check of checks) { + if (check.condition) warnings.push(check.message); } return warnings; diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 615fb8a85f7..99464a46533 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -127,9 +127,17 @@ export const FolderTrustDialog: React.FC = ({ const overhead = 3 + options.length + 2 + 1 + 2; const scrollableHeight = Math.max(4, terminalHeight - overhead); + const groups = [ + { label: 'Commands', items: discoveryResults?.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults?.hooks ?? [] }, + { label: 'Skills', items: discoveryResults?.skills ?? [] }, + { label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, + ].filter((g) => g.items.length > 0); + const discoveryContent = ( - + Trusting a folder allows Gemini CLI to load its local configurations, including custom commands, hooks, MCP servers, agent skills, and @@ -143,8 +151,8 @@ export const FolderTrustDialog: React.FC = ({ ❌ Discovery Errors: - {discoveryResults.discoveryErrors.map((error, index) => ( - + {discoveryResults.discoveryErrors.map((error, i) => ( + • {error} ))} @@ -153,12 +161,12 @@ export const FolderTrustDialog: React.FC = ({ {hasWarnings && ( - + ⚠️ Security Warnings: - {discoveryResults.securityWarnings.map((warning, index) => ( - - • {warning} + {discoveryResults.securityWarnings.map((warning, i) => ( + + • {warning} ))} @@ -169,34 +177,37 @@ export const FolderTrustDialog: React.FC = ({ This folder contains: - {[ - { label: 'Commands', items: discoveryResults.commands }, - { label: 'MCP Servers', items: discoveryResults.mcps }, - { label: 'Hooks', items: discoveryResults.hooks }, - { label: 'Skills', items: discoveryResults.skills }, - { - label: 'Setting overrides', - items: discoveryResults.settings, - }, - ] - .filter((group) => group.items.length > 0) - .map((group) => ( - - - • {group.label} ({group.items.length}): - - {group.items.map((item, idx) => ( - - - {item} - - ))} + {groups.map((group) => ( + + + • {group.label} ({group.items.length}): + + {group.items.map((item, idx) => ( + + - {item} ))} + + ))} )} ); + const title = ( + + Do you trust the files in this folder? + + ); + + const selectOptions = ( + + ); + const renderContent = () => { if (isAlternateBuffer) { return ( @@ -207,9 +218,7 @@ export const FolderTrustDialog: React.FC = ({ borderColor={borderColor} borderDimColor={false} > - - Do you trust the files in this folder? - + {title} = ({ - - + + {selectOptions} @@ -263,11 +268,7 @@ export const FolderTrustDialog: React.FC = ({ padding={1} width="100%" > - - - Do you trust the files in this folder? - - + {title} = ({ {discoveryContent} - - - + {selectOptions} ); }; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index a56e457f816..905a7d402aa 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -103,8 +103,6 @@ export const useFolderTrust = ( onTrustChange(currentIsTrusted); setIsTrusted(currentIsTrusted); - // logic: we restart if the trust state *effectively* changes from the previous state. - // previous state was `isTrusted`. If undefined, we assume false (untrusted). const wasTrusted = isTrusted ?? false; if (wasTrusted !== currentIsTrusted) { From c077b309bdba69960e38f582dcfb4b8467aaa4bc Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 18 Feb 2026 16:52:06 -0800 Subject: [PATCH 3/6] chore: revert settings.json changes --- .gemini/settings.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .gemini/settings.json diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 00000000000..ea7f0947ccf --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,9 @@ +{ + "experimental": { + "plan": true, + "extensionReloading": true + }, + "general": { + "devtools": true + } +} From 51540f12bf85a07623a20932426a52cfc7c439e5 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:22:22 -0800 Subject: [PATCH 4/6] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/ui/hooks/useFolderTrust.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 0a4dc0f0e4b..f532b0dcc15 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -40,13 +40,16 @@ export const useFolderTrust = ( const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); if (trusted === undefined || trusted === false) { - void FolderTrustDiscoveryService.discover(process.cwd()).then( - (results) => { + void FolderTrustDiscoveryService.discover(process.cwd()) + .then((results) => { if (isMounted) { setDiscoveryResults(results); } - }, - ); + }) + .catch(() => { + // Silently ignore discovery errors as they are handled within the service + // and reported via results.discoveryErrors if successful. + }); } const showUntrustedMessage = () => { From b94ac606096d704fcd8a4cf024d26dfb2ad4e877 Mon Sep 17 00:00:00 2001 From: galz10 Date: Thu, 19 Feb 2026 13:36:34 -0800 Subject: [PATCH 5/6] fix(cli): improve folder trust discovery robustness and UI - Robust JSON Parsing: Update `FolderTrustDiscoveryService` to handle non-object, null, or array values in `settings.json` without crashing, and add regression tests for these cases. - Type Safety: Fix unsafe type assertions in `FolderTrustDiscoveryService` using type guards (`isRecord`) and `instanceof Error` for better runtime safety and ESLint compliance. - ANSI Stripping: Use `strip-ansi` in `FolderTrustDialog` to ensure that raw ANSI escape codes from discovery errors, security warnings, or settings are not rendered as text in the UI. - UI Refinements: - Update `MaxSizedBox` to use `theme.text.secondary` for truncation messages. - Fix `Scrollable` auto-scroll logic to correctly identify "at bottom" state and default to top-aligned scrolling for new content unless `scrollToBottom` is explicitly set. - Test Improvements: Update `FolderTrustDialog` tests to properly wait for rendering (`waitUntilReady`) and add coverage for ANSI stripping. --- .../FolderTrustDiscoveryService.test.ts | 34 ++++++++- .../services/FolderTrustDiscoveryService.ts | 69 +++++++++--------- .../ui/components/FolderTrustDialog.test.tsx | 71 +++++++++++++++---- .../src/ui/components/FolderTrustDialog.tsx | 7 +- .../src/ui/components/shared/MaxSizedBox.tsx | 10 +-- .../ui/components/shared/Scrollable.test.tsx | 26 ++++++- .../src/ui/components/shared/Scrollable.tsx | 6 +- 7 files changed, 155 insertions(+), 68 deletions(-) diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.test.ts b/packages/cli/src/services/FolderTrustDiscoveryService.test.ts index 4de6913f9b1..093867a15d5 100644 --- a/packages/cli/src/services/FolderTrustDiscoveryService.test.ts +++ b/packages/cli/src/services/FolderTrustDiscoveryService.test.ts @@ -123,8 +123,38 @@ describe('FolderTrustDiscoveryService', () => { await fs.writeFile(path.join(geminiDir, 'settings.json'), 'invalid json'); const results = await FolderTrustDiscoveryService.discover(tempDir); - expect(results.mcps).toHaveLength(0); - expect(results.hooks).toHaveLength(0); + expect(results.discoveryErrors[0]).toContain( + 'Failed to discover settings: Unexpected token', + ); + }); + + it('should handle null settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'null'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle array settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '[]'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle string settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '"string"'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); expect(results.settings).toHaveLength(0); }); }); diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.ts b/packages/cli/src/services/FolderTrustDiscoveryService.ts index 8c7e2a4c30c..3c3a26b70ef 100644 --- a/packages/cli/src/services/FolderTrustDiscoveryService.ts +++ b/packages/cli/src/services/FolderTrustDiscoveryService.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs/promises'; import { existsSync } from 'node:fs'; import * as path from 'node:path'; import stripJsonComments from 'strip-json-comments'; -import { GEMINI_DIR } from '@google/gemini-cli-core'; +import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core'; export interface FolderDiscoveryResults { commands: string[]; @@ -68,7 +68,7 @@ export class FolderTrustDiscoveryService { .map((f) => path.basename(f, '.toml')); } catch (e) { results.discoveryErrors.push( - `Failed to discover commands: ${(e as Error).message}`, + `Failed to discover commands: ${e instanceof Error ? e.message : String(e)}`, ); } } @@ -92,7 +92,7 @@ export class FolderTrustDiscoveryService { } } catch (e) { results.discoveryErrors.push( - `Failed to discover skills: ${(e as Error).message}`, + `Failed to discover skills: ${e instanceof Error ? e.message : String(e)}`, ); } } @@ -107,10 +107,12 @@ export class FolderTrustDiscoveryService { try { const content = await fs.readFile(settingsPath, 'utf-8'); - const settings = JSON.parse(stripJsonComments(content)) as Record< - string, - unknown - >; + const settings = JSON.parse(stripJsonComments(content)) as unknown; + + if (!this.isRecord(settings)) { + debugLogger.debug('Settings must be a JSON object'); + return; + } results.settings = Object.keys(settings).filter( (key) => !['mcpServers', 'hooks', '$schema'].includes(key), @@ -119,31 +121,18 @@ export class FolderTrustDiscoveryService { results.securityWarnings = this.collectSecurityWarnings(settings); const mcpServers = settings['mcpServers']; - if ( - mcpServers && - typeof mcpServers === 'object' && - !Array.isArray(mcpServers) - ) { + if (this.isRecord(mcpServers)) { results.mcps = Object.keys(mcpServers); } const hooksConfig = settings['hooks']; - if ( - hooksConfig && - typeof hooksConfig === 'object' && - !Array.isArray(hooksConfig) - ) { + if (this.isRecord(hooksConfig)) { const hooks = new Set(); for (const event of Object.values(hooksConfig)) { if (!Array.isArray(event)) continue; for (const hook of event) { - if ( - hook && - typeof hook === 'object' && - 'command' in hook && - typeof (hook as Record)['command'] === 'string' - ) { - hooks.add((hook as Record)['command']); + if (this.isRecord(hook) && typeof hook['command'] === 'string') { + hooks.add(hook['command']); } } } @@ -151,7 +140,7 @@ export class FolderTrustDiscoveryService { } } catch (e) { results.discoveryErrors.push( - `Failed to discover settings: ${(e as Error).message}`, + `Failed to discover settings: ${e instanceof Error ? e.message : String(e)}`, ); } } @@ -161,16 +150,22 @@ export class FolderTrustDiscoveryService { ): string[] { const warnings: string[] = []; - const tools = settings['tools'] as Record | undefined; - const experimental = settings['experimental'] as - | Record - | undefined; - const security = settings['security'] as - | Record - | undefined; - const folderTrust = security?.['folderTrust'] as - | Record - | undefined; + const tools = this.isRecord(settings['tools']) + ? settings['tools'] + : undefined; + + const experimental = this.isRecord(settings['experimental']) + ? settings['experimental'] + : undefined; + + const security = this.isRecord(settings['security']) + ? settings['security'] + : undefined; + + const folderTrust = + security && this.isRecord(security['folderTrust']) + ? security['folderTrust'] + : undefined; const allowedTools = tools?.['allowed']; @@ -200,4 +195,8 @@ export class FolderTrustDiscoveryService { return warnings; } + + private static isRecord(val: unknown): val is Record { + return !!val && typeof val === 'object' && !Array.isArray(val); + } } diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 129a910cfb1..44d0feea19e 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -55,7 +55,7 @@ describe('FolderTrustDialog', () => { unmount(); }); - it('should truncate discovery results when they exceed maxDiscoveryHeight', () => { + it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => { // maxDiscoveryHeight = 24 - 15 = 9. const discoveryResults = { commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), @@ -66,7 +66,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { }, ); + await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('hidden'); unmount(); }); - it('should adjust maxHeight based on terminal rows', () => { + it('should adjust maxHeight based on terminal rows', async () => { mockedRows.current = 14; // maxHeight = 14 - 10 = 4 const discoveryResults = { commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], @@ -94,7 +95,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { }, ); + await waitUntilReady(); // With maxHeight=4, the intro text (4 lines) will take most of the space. // The discovery results will likely be hidden. expect(lastFrame()).toContain('hidden'); unmount(); }); - it('should use minimum maxHeight of 4', () => { + it('should use minimum maxHeight of 4', async () => { mockedRows.current = 8; // 8 - 10 = -2, should use 4 const discoveryResults = { commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], @@ -123,7 +125,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { }, ); + await waitUntilReady(); expect(lastFrame()).toContain('hidden'); unmount(); }); @@ -312,7 +315,7 @@ describe('FolderTrustDialog', () => { unmount(); }); - it('should display discovery results when provided', () => { + it('should display discovery results when provided', async () => { mockedRows.current = 40; // Increase height to show all results const discoveryResults = { commands: ['cmd1', 'cmd2'], @@ -323,7 +326,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { { width: 80 }, ); + await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('• Commands (2):'); expect(lastFrame()).toContain('- cmd1'); @@ -347,7 +351,7 @@ describe('FolderTrustDialog', () => { unmount(); }); - it('should display security warnings when provided', () => { + it('should display security warnings when provided', async () => { const discoveryResults = { commands: [], mcps: [], @@ -357,19 +361,20 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Security Warnings:'); expect(lastFrame()).toContain('Dangerous setting detected!'); unmount(); }); - it('should display discovery errors when provided', () => { + it('should display discovery errors when provided', async () => { const discoveryResults = { commands: [], mcps: [], @@ -379,19 +384,20 @@ describe('FolderTrustDialog', () => { discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Discovery Errors:'); expect(lastFrame()).toContain('Failed to load custom commands'); unmount(); }); - it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', () => { + it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => { const discoveryResults = { commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`), mcps: [], @@ -401,7 +407,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { }, ); + await waitUntilReady(); // In alternate buffer + expanded, the title should be visible (StickyHeader) expect(lastFrame()).toContain('Do you trust the files in this folder?'); // And it should NOT use MaxSizedBox truncation expect(lastFrame()).not.toContain('hidden'); unmount(); }); + + it('should strip ANSI codes from discovery results', async () => { + const ansiRed = '\u001b[31m'; + const ansiReset = '\u001b[39m'; + + const discoveryResults = { + commands: [`${ansiRed}cmd-with-ansi${ansiReset}`], + mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], + hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], + skills: [`${ansiRed}skill-with-ansi${ansiReset}`], + settings: [`${ansiRed}setting-with-ansi${ansiReset}`], + discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], + securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], + }; + + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 100, uiState: { terminalHeight: 40 } }, + ); + + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('cmd-with-ansi'); + expect(output).toContain('mcp-with-ansi'); + expect(output).toContain('hook-with-ansi'); + expect(output).toContain('skill-with-ansi'); + expect(output).toContain('setting-with-ansi'); + expect(output).toContain('error-with-ansi'); + expect(output).toContain('warning-with-ansi'); + + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 99464a46533..33b614734c8 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -8,6 +8,7 @@ import { Box, Text } from 'ink'; import type React from 'react'; import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; +import stripAnsi from 'strip-ansi'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; @@ -153,7 +154,7 @@ export const FolderTrustDialog: React.FC = ({ {discoveryResults.discoveryErrors.map((error, i) => ( - • {error} + • {stripAnsi(error)} ))} @@ -166,7 +167,7 @@ export const FolderTrustDialog: React.FC = ({ {discoveryResults.securityWarnings.map((warning, i) => ( - • {warning} + • {stripAnsi(warning)} ))} @@ -184,7 +185,7 @@ export const FolderTrustDialog: React.FC = ({ {group.items.map((item, idx) => ( - - {item} + - {stripAnsi(item)} ))} diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 44232122324..fef1e11bd5e 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -23,7 +23,6 @@ interface MaxSizedBoxProps { maxHeight?: number; overflowDirection?: 'top' | 'bottom'; additionalHiddenLinesCount?: number; - onOverflowChange?: (isOverflowing: boolean) => void; } /** @@ -36,7 +35,6 @@ export const MaxSizedBox: React.FC = ({ maxHeight, overflowDirection = 'top', additionalHiddenLinesCount = 0, - onOverflowChange, }) => { const id = useId(); const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; @@ -73,10 +71,6 @@ export const MaxSizedBox: React.FC = ({ (effectiveMaxHeight !== undefined && contentHeight > effectiveMaxHeight) || additionalHiddenLinesCount > 0; - useEffect(() => { - onOverflowChange?.(isOverflowing); - }, [isOverflowing, onOverflowChange]); - // If we're overflowing, we need to hide at least 1 line for the message. const visibleContentHeight = isOverflowing && effectiveMaxHeight !== undefined @@ -121,7 +115,7 @@ export const MaxSizedBox: React.FC = ({ flexShrink={0} > {totalHiddenLines > 0 && overflowDirection === 'top' && ( - + ... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '} hidden ... @@ -142,7 +136,7 @@ export const MaxSizedBox: React.FC = ({ {totalHiddenLines > 0 && overflowDirection === 'bottom' && ( - + ... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '} hidden ... diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index 8c765c5acca..db32a1a2e9c 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.test.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -108,7 +108,27 @@ describe('', () => { throw new Error('capturedEntry is undefined'); } - // Initial state (starts at bottom because of auto-scroll logic) + // Initial state (starts at top by default) + expect(capturedEntry.getScrollState().scrollTop).toBe(0); + + // Initial state with scrollToBottom={true} + unmount(); + const { waitUntilReady: waitUntilReady2, unmount: unmount2 } = + renderWithProviders( + + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + Line 8 + Line 9 + Line 10 + , + ); + await waitUntilReady2(); expect(capturedEntry.getScrollState().scrollTop).toBe(5); // Call scrollBy multiple times (upwards) in the same tick @@ -116,14 +136,14 @@ describe('', () => { capturedEntry!.scrollBy(-1); capturedEntry!.scrollBy(-1); }); - // Should have moved up by 2 + // Should have moved up by 2 (5 -> 3) expect(capturedEntry.getScrollState().scrollTop).toBe(3); await act(async () => { capturedEntry!.scrollBy(-2); }); expect(capturedEntry.getScrollState().scrollTop).toBe(1); - unmount(); + unmount2(); }); describe('keypress handling', () => { diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 8c53266d303..a830cbecfe8 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -54,8 +54,7 @@ export const Scrollable: React.FC = ({ const childrenCountRef = useRef(0); // This effect needs to run on every render to correctly measure the container - // and scroll to the bottom if new children are added. The if conditions - // prevent infinite loops. + // and scroll to the bottom if new children are added. // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!ref.current) { @@ -64,7 +63,8 @@ export const Scrollable: React.FC = ({ const innerHeight = Math.round(getInnerHeight(ref.current)); const scrollHeight = Math.round(getScrollHeight(ref.current)); - const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1; + const isAtBottom = + scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1; if ( size.innerHeight !== innerHeight || From 67558e97dfedb7d076f98db9584896133c7286e4 Mon Sep 17 00:00:00 2001 From: galz10 Date: Thu, 19 Feb 2026 14:56:21 -0800 Subject: [PATCH 6/6] chore: addressed local review comments --- docs/cli/trusted-folders.md | 31 +++++++++++++++++++ .../ui/components/FolderTrustDialog.test.tsx | 4 +++ .../src/ui/components/FolderTrustDialog.tsx | 6 ++-- .../cli/src/ui/contexts/UIStateContext.tsx | 2 +- .../cli/src/ui/hooks/useFolderTrust.test.ts | 9 ++---- packages/cli/src/ui/hooks/useFolderTrust.ts | 8 +++-- packages/core/package.json | 1 + packages/core/src/index.ts | 1 + .../FolderTrustDiscoveryService.test.ts | 5 +-- .../services/FolderTrustDiscoveryService.ts | 27 +++++++++++----- 10 files changed, 73 insertions(+), 21 deletions(-) rename packages/{cli => core}/src/services/FolderTrustDiscoveryService.test.ts (97%) rename packages/{cli => core}/src/services/FolderTrustDiscoveryService.ts (89%) diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index 7f6e668c244..c271a0dba25 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -38,6 +38,37 @@ folder, a dialog will automatically appear, prompting you to make a choice: Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you will only be asked once per folder. +## Understanding folder contents: The discovery phase + +Before you make a choice, the Gemini CLI performs a **discovery phase** to scan +the folder for potential configurations. This information is displayed in the +trust dialog to help you make an informed decision. + +The discovery UI lists the following categories of items found in the project: + +- **Commands**: Custom `.toml` command definitions that add new functionality. +- **MCP Servers**: Configured Model Context Protocol servers that the CLI will + attempt to connect to. +- **Hooks**: System or custom hooks that can intercept and modify CLI behavior. +- **Skills**: Local agent skills that provide specialized capabilities. +- **Setting overrides**: Any project-specific configurations that override your + global user settings. + +### Security warnings and errors + +The trust dialog also highlights critical information that requires your +attention: + +- **Security Warnings**: The CLI will explicitly flag potentially dangerous + settings, such as auto-approving certain tools or disabling the security + sandbox. +- **Discovery Errors**: If the CLI encounters issues while scanning the folder + (e.g., a malformed `settings.json` file), these errors will be displayed + prominently. + +By reviewing these details, you can ensure that you only grant trust to projects +that you know are safe. + ## Why trust matters: The impact of an untrusted workspace When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode" diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 44d0feea19e..a227047fba6 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -42,6 +42,10 @@ describe('FolderTrustDialog', () => { mockedRows.current = 24; }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should render the dialog with title and description', async () => { const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 33b614734c8..70cfd9fd4c8 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -18,8 +18,10 @@ import * as process from 'node:process'; import * as path from 'node:path'; import { relaunchApp } from '../../utils/processUtils.js'; import { runExitCleanup } from '../../utils/cleanup.js'; -import { ExitCodes } from '@google/gemini-cli-core'; -import type { FolderDiscoveryResults } from '../../services/FolderTrustDiscoveryService.js'; +import { + ExitCodes, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; import { useUIState } from '../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9539c3a96e4..79ed2665eb4 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + FolderDiscoveryResults, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; @@ -54,7 +55,6 @@ import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; import type { BackgroundShell } from '../hooks/shellCommandProcessor.js'; -import type { FolderDiscoveryResults } from '../../services/FolderTrustDiscoveryService.js'; export interface QuotaState { userTier: UserTierId | undefined; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index e9b4e673053..277180404c6 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -26,12 +26,6 @@ import * as trustedFolders from '../../config/trustedFolders.js'; import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; -vi.mock('../../services/FolderTrustDiscoveryService.js', () => ({ - FolderTrustDiscoveryService: { - discover: vi.fn(() => new Promise(() => {})), - }, -})); - const mockedCwd = vi.hoisted(() => vi.fn()); const mockedExit = vi.hoisted(() => vi.fn()); @@ -42,6 +36,9 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actual, isHeadlessMode: vi.fn().mockReturnValue(false), + FolderTrustDiscoveryService: { + discover: vi.fn(() => new Promise(() => {})), + }, }; }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index f532b0dcc15..e2a5373e34b 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -14,12 +14,14 @@ import { } from '../../config/trustedFolders.js'; import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; -import { runExitCleanup } from '../../utils/cleanup.js'; import { + coreEvents, + ExitCodes, + isHeadlessMode, FolderTrustDiscoveryService, type FolderDiscoveryResults, -} from '../../services/FolderTrustDiscoveryService.js'; +} from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; export const useFolderTrust = ( settings: LoadedSettings, diff --git a/packages/core/package.json b/packages/core/package.json index 529a788b444..a8e3f4c0d9e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,6 +72,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", + "strip-json-comments": "^3.1.1", "systeminformation": "^5.25.11", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f82486173d..f635328f405 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,6 +110,7 @@ export * from './utils/constants.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.test.ts b/packages/core/src/services/FolderTrustDiscoveryService.test.ts similarity index 97% rename from packages/cli/src/services/FolderTrustDiscoveryService.test.ts rename to packages/core/src/services/FolderTrustDiscoveryService.test.ts index 093867a15d5..b6d7d7734a8 100644 --- a/packages/cli/src/services/FolderTrustDiscoveryService.test.ts +++ b/packages/core/src/services/FolderTrustDiscoveryService.test.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import { FolderTrustDiscoveryService } from './FolderTrustDiscoveryService.js'; -import { GEMINI_DIR } from '@google/gemini-cli-core'; +import { GEMINI_DIR } from '../utils/paths.js'; describe('FolderTrustDiscoveryService', () => { let tempDir: string; @@ -21,6 +21,7 @@ describe('FolderTrustDiscoveryService', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/cli/src/services/FolderTrustDiscoveryService.ts b/packages/core/src/services/FolderTrustDiscoveryService.ts similarity index 89% rename from packages/cli/src/services/FolderTrustDiscoveryService.ts rename to packages/core/src/services/FolderTrustDiscoveryService.ts index 3c3a26b70ef..e81273af220 100644 --- a/packages/cli/src/services/FolderTrustDiscoveryService.ts +++ b/packages/core/src/services/FolderTrustDiscoveryService.ts @@ -5,10 +5,11 @@ */ import * as fs from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import * as path from 'node:path'; import stripJsonComments from 'strip-json-comments'; -import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core'; +import { GEMINI_DIR } from '../utils/paths.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; export interface FolderDiscoveryResults { commands: string[]; @@ -42,7 +43,7 @@ export class FolderTrustDiscoveryService { }; const geminiDir = path.join(workspaceDir, GEMINI_DIR); - if (!existsSync(geminiDir)) { + if (!(await this.exists(geminiDir))) { return results; } @@ -60,7 +61,7 @@ export class FolderTrustDiscoveryService { results: FolderDiscoveryResults, ) { const commandsDir = path.join(geminiDir, 'commands'); - if (existsSync(commandsDir)) { + if (await this.exists(commandsDir)) { try { const files = await fs.readdir(commandsDir, { recursive: true }); results.commands = files @@ -79,13 +80,13 @@ export class FolderTrustDiscoveryService { results: FolderDiscoveryResults, ) { const skillsDir = path.join(geminiDir, 'skills'); - if (existsSync(skillsDir)) { + if (await this.exists(skillsDir)) { try { const entries = await fs.readdir(skillsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); - if (existsSync(skillMdPath)) { + if (await this.exists(skillMdPath)) { results.skills.push(entry.name); } } @@ -103,7 +104,7 @@ export class FolderTrustDiscoveryService { results: FolderDiscoveryResults, ) { const settingsPath = path.join(geminiDir, 'settings.json'); - if (!existsSync(settingsPath)) return; + if (!(await this.exists(settingsPath))) return; try { const content = await fs.readFile(settingsPath, 'utf-8'); @@ -199,4 +200,16 @@ export class FolderTrustDiscoveryService { private static isRecord(val: unknown): val is Record { return !!val && typeof val === 'object' && !Array.isArray(val); } + + private static async exists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return false; + } + throw e; + } + } }