diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 554d5cd53d..2172190e9b 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -9,11 +9,7 @@ import { policiesCommand } from './policiesCommand.js'; import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { - type Config, - PolicyDecision, - ApprovalMode, -} from '@google/gemini-cli-core'; +import { type Config, PolicyDecision } from '@google/gemini-cli-core'; describe('policiesCommand', () => { let mockContext: ReturnType; @@ -60,13 +56,47 @@ describe('policiesCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: 'No active policies.', + text: 'No custom policies configured.', }), expect.any(Number), ); }); - it('should list policies grouped by mode', async () => { + it('should show no-policies message when only default policies exist', async () => { + const mockRules = [ + { + decision: PolicyDecision.ALLOW, + toolName: 'glob', + priority: 1.05, + source: 'Default: read-only.toml', + }, + { + decision: PolicyDecision.ASK_USER, + toolName: 'run_shell_command', + priority: 1.01, + source: 'Default: write.toml', + }, + ]; + const mockPolicyEngine = { + getRules: vi.fn().mockReturnValue(mockRules), + }; + mockContext.services.config = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + } as unknown as Config; + + const listCommand = policiesCommand.subCommands![0]; + await listCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'No custom policies configured.', + }), + expect.any(Number), + ); + }); + + it('should return custom_dialog when policies exist', async () => { const mockRules = [ { decision: PolicyDecision.DENY, @@ -85,79 +115,106 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; + const mockToolRegistry = { + getTool: vi.fn().mockReturnValue(undefined), + }; mockContext.services.config = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; - await listCommand.action!(mockContext, ''); + const result = await listCommand.action!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('**Active Policies**'), - }), - expect.any(Number), - ); - - const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; - const content = (call[0] as { text: string }).text; - - expect(content).toContain('### Normal Mode Policies'); - expect(content).toContain( - '### Auto Edit Mode Policies (combined with normal mode policies)', - ); - expect(content).toContain( - '### Yolo Mode Policies (combined with normal mode policies)', - ); - expect(content).toContain('### Plan Mode Policies'); - expect(content).toContain( - '**DENY** tool: `dangerousTool` [Priority: 10]', - ); - expect(content).toContain( - '**ALLOW** all tools (args match: `safe`) [Source: test.toml]', - ); - expect(content).toContain('**ASK_USER** all tools'); + expect(result).toMatchObject({ + type: 'custom_dialog', + }); + expect(result).toHaveProperty('component'); }); - it('should show plan-only rules in plan mode section', async () => { + it('should populate toolDisplayNames from tool registry', async () => { const mockRules = [ { decision: PolicyDecision.ALLOW, - toolName: 'glob', - priority: 70, - modes: [ApprovalMode.PLAN], - }, - { - decision: PolicyDecision.DENY, - priority: 60, - modes: [ApprovalMode.PLAN], + toolName: 'run_shell_command', + priority: 5, }, { decision: PolicyDecision.ALLOW, - toolName: 'shell', - priority: 50, + toolName: 'glob', + priority: 3, }, ]; const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; + const mockToolRegistry = { + getTool: vi.fn().mockImplementation((name: string) => { + const displayNames: Record = { + run_shell_command: 'Shell', + glob: 'FindFiles', + }; + if (displayNames[name]) { + return { displayName: displayNames[name] }; + } + return undefined; + }), + }; mockContext.services.config = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; - await listCommand.action!(mockContext, ''); + const result = await listCommand.action!(mockContext, ''); - const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; - const content = (call[0] as { text: string }).text; + expect(result).toMatchObject({ type: 'custom_dialog' }); + // Verify getTool was called for each unique toolName + expect(mockToolRegistry.getTool).toHaveBeenCalledWith( + 'run_shell_command', + ); + expect(mockToolRegistry.getTool).toHaveBeenCalledWith('glob'); + }); + }); - // Plan-only rules appear under Plan Mode section - expect(content).toContain('### Plan Mode Policies'); - // glob ALLOW is plan-only, should appear in plan section - expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); - // shell ALLOW has no modes (applies to all), appears in normal section - expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]'); + describe('parent command', () => { + it('should also return custom_dialog when policies exist', async () => { + const mockRules = [ + { + decision: PolicyDecision.ALLOW, + toolName: 'glob', + priority: 5, + }, + ]; + const mockPolicyEngine = { + getRules: vi.fn().mockReturnValue(mockRules), + }; + const mockToolRegistry = { + getTool: vi.fn().mockReturnValue(undefined), + }; + mockContext.services.config = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + } as unknown as Config; + + const result = await policiesCommand.action!(mockContext, ''); + + expect(result).toMatchObject({ type: 'custom_dialog' }); + expect(result).toHaveProperty('component'); + }); + + it('should show error if config is missing', async () => { + mockContext.services.config = null; + + await policiesCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Error: Config not available.', + }), + expect.any(Number), + ); }); }); }); diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index f4bd13de28..8a24dd11c9 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -4,111 +4,86 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core'; +import React from 'react'; +import type { PolicyRule } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; +import { PoliciesDialog } from '../components/PoliciesDialog.js'; -interface CategorizedRules { - normal: PolicyRule[]; - autoEdit: PolicyRule[]; - yolo: PolicyRule[]; - plan: PolicyRule[]; +function buildToolDisplayNames( + rules: readonly PolicyRule[], + config: { + getToolRegistry: () => { + getTool: (name: string) => { displayName: string } | undefined; + }; + }, +): Map { + const toolDisplayNames = new Map(); + const toolRegistry = config.getToolRegistry(); + for (const rule of rules) { + if (rule.toolName && !toolDisplayNames.has(rule.toolName)) { + const tool = toolRegistry.getTool(rule.toolName); + if (tool) { + toolDisplayNames.set(rule.toolName, tool.displayName); + } + } + } + return toolDisplayNames; } -const categorizeRulesByMode = ( - rules: readonly PolicyRule[], -): CategorizedRules => { - const result: CategorizedRules = { - normal: [], - autoEdit: [], - yolo: [], - plan: [], +const policiesDialogAction: NonNullable = async ( + context, +) => { + const { config } = context.services; + if (!config) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Error: Config not available.', + }, + Date.now(), + ); + return; + } + + const policyEngine = config.getPolicyEngine(); + const allRules = policyEngine.getRules(); + + // Filter out built-in default policies — users only care about rules they + // (or their team / admin / extensions) configured. + const rules = allRules.filter( + (rule) => !rule.source?.startsWith('Default: '), + ); + + if (rules.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'No custom policies configured.', + }, + Date.now(), + ); + return; + } + + const toolDisplayNames = buildToolDisplayNames(rules, config); + + return { + type: 'custom_dialog' as const, + component: React.createElement(PoliciesDialog, { + rules, + toolDisplayNames, + onClose: () => context.ui.removeComponent(), + }), }; - const ALL_MODES = Object.values(ApprovalMode); - rules.forEach((rule) => { - const modes = rule.modes?.length ? rule.modes : ALL_MODES; - const modeSet = new Set(modes); - if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule); - if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule); - if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule); - if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule); - }); - return result; }; -const formatRule = (rule: PolicyRule, i: number) => - `${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` + - (rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') + - (rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') + - (rule.source ? ` [Source: ${rule.source}]` : ''); - -const formatSection = (title: string, rules: PolicyRule[]) => - `### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`; - const listPoliciesCommand: SlashCommand = { name: 'list', description: 'List all active policies grouped by mode', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async (context) => { - const { config } = context.services; - if (!config) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Error: Config not available.', - }, - Date.now(), - ); - return; - } - - const policyEngine = config.getPolicyEngine(); - const rules = policyEngine.getRules(); - - if (rules.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'No active policies.', - }, - Date.now(), - ); - return; - } - - const categorized = categorizeRulesByMode(rules); - const normalRulesSet = new Set(categorized.normal); - const uniqueAutoEdit = categorized.autoEdit.filter( - (rule) => !normalRulesSet.has(rule), - ); - const uniqueYolo = categorized.yolo.filter( - (rule) => !normalRulesSet.has(rule), - ); - const uniquePlan = categorized.plan.filter( - (rule) => !normalRulesSet.has(rule), - ); - - let content = '**Active Policies**\n\n'; - content += formatSection('Normal Mode Policies', categorized.normal); - content += formatSection( - 'Auto Edit Mode Policies (combined with normal mode policies)', - uniqueAutoEdit, - ); - content += formatSection( - 'Yolo Mode Policies (combined with normal mode policies)', - uniqueYolo, - ); - content += formatSection('Plan Mode Policies', uniquePlan); - - context.ui.addItem( - { - type: MessageType.INFO, - text: content, - }, - Date.now(), - ); - }, + action: policiesDialogAction, }; export const policiesCommand: SlashCommand = { @@ -116,5 +91,6 @@ export const policiesCommand: SlashCommand = { description: 'Manage policies', kind: CommandKind.BUILT_IN, autoExecute: false, + action: policiesDialogAction, subCommands: [listPoliciesCommand], }; diff --git a/packages/cli/src/ui/components/PoliciesDialog.tsx b/packages/cli/src/ui/components/PoliciesDialog.tsx new file mode 100644 index 0000000000..1b19a34588 --- /dev/null +++ b/packages/cli/src/ui/components/PoliciesDialog.tsx @@ -0,0 +1,422 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { AsyncFzf } from 'fzf'; +import { PolicyDecision, type PolicyRule } from '@google/gemini-cli-core'; +import { theme } from '../semantic-colors.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; +import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; +import { TextInput } from './shared/TextInput.js'; +import { TabHeader, type Tab } from './shared/TabHeader.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { + buildPolicyListItems, + type PolicyListItem, +} from '../utils/policyUtils.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { isNarrowWidth } from '../utils/isNarrowWidth.js'; + +interface PoliciesDialogProps { + rules: readonly PolicyRule[]; + toolDisplayNames: Map; + onClose: () => void; +} + +const TABS = [ + { + decision: PolicyDecision.ALLOW, + label: 'Allow', + description: 'Tools that run automatically without confirmation.', + }, + { + decision: PolicyDecision.ASK_USER, + label: 'Ask', + description: 'Tools that require your approval before running.', + }, + { + decision: PolicyDecision.DENY, + label: 'Deny', + description: 'Tools that are blocked from running.', + }, +] as const; + +interface FzfResult { + item: PolicyListItem; +} + +export function PoliciesDialog({ + rules, + toolDisplayNames, + onClose, +}: PoliciesDialogProps): React.JSX.Element { + const { terminalHeight, terminalWidth, staticExtraHeight, constrainHeight } = + useUIState(); + + const [activeTabIndex, setActiveTabIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [filteredItems, setFilteredItems] = useState( + null, + ); + + const activeTab = TABS[activeTabIndex]; + + // Compute available height independently of the dialog's own rendered height + // to break the circular dependency (dialog height -> controlsHeight -> + // availableTerminalHeight -> dialog height). + const dialogAvailableHeight = constrainHeight + ? terminalHeight - staticExtraHeight + : undefined; + + const { effectiveMaxItemsToShow, showTabDescription, showHelpText } = + useMemo(() => { + if (!dialogAvailableHeight) { + return { + effectiveMaxItemsToShow: 8, + showTabDescription: true, + showHelpText: true, + }; + } + + // On small terminals (< 20 lines), hide tab description and help text + // to reclaim 2 lines for content. + const tight = dialogAvailableHeight < 20; + const showDesc = !tight; + const showHelp = !tight; + + // Fixed layout lines (full): + // 2 (border) + 2 (padding) + 1 (title/tabs) + 1 (tab description) + + // 3 (search) + 1 (list margin) + 2 (arrows) + 1 (footer margin) + + // 1 (count text) + 1 (help text) = 15 + // When hiding tab description and help text: 13 + let staticHeight = 13; // base without optional elements + if (showDesc) staticHeight += 1; + if (showHelp) staticHeight += 1; + + const ITEM_HEIGHT = 2; + const availableForItems = dialogAvailableHeight - staticHeight; + const maxItems = Math.max(1, Math.floor(availableForItems / ITEM_HEIGHT)); + + return { + effectiveMaxItemsToShow: maxItems, + showTabDescription: showDesc, + showHelpText: showHelp, + }; + }, [dialogAvailableHeight]); + + // Build items for all tabs + const itemsByDecision = useMemo(() => { + const map = new Map(); + for (const tab of TABS) { + map.set( + tab.decision, + buildPolicyListItems(rules, toolDisplayNames, tab.decision), + ); + } + return map; + }, [rules, toolDisplayNames]); + + // Build tab headers with optional counts + const showTabCounts = !isNarrowWidth(terminalWidth); + const policyTabs: Tab[] = useMemo( + () => + TABS.map((tab) => { + const count = itemsByDecision.get(tab.decision)?.length ?? 0; + return { + key: tab.decision, + header: showTabCounts ? `${tab.label} (${count})` : tab.label, + }; + }), + [itemsByDecision, showTabCounts], + ); + + // Get total count for current tab (unfiltered) + const allTabItems = itemsByDecision.get(activeTab.decision) ?? []; + + // Build fzf instance per tab + const fzfInstances = useMemo(() => { + const map = new Map>(); + for (const tab of TABS) { + const items = itemsByDecision.get(tab.decision) ?? []; + map.set( + tab.decision, + new AsyncFzf(items, { + selector: (item: PolicyListItem) => item.searchText, + fuzzy: 'v2', + casing: 'case-insensitive', + }), + ); + } + return map; + }, [itemsByDecision]); + + // Perform search when query or tab changes + useEffect(() => { + let active = true; + if (!searchQuery.trim()) { + setFilteredItems(null); + return; + } + + const fzf = fzfInstances.get(activeTab.decision); + if (!fzf) return; + + const doSearch = async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const results = await fzf.find(searchQuery); + if (!active) return; + setFilteredItems(results.map((r: FzfResult) => r.item)); + }; + + void doSearch(); + return () => { + active = false; + }; + }, [searchQuery, activeTab.decision, fzfInstances]); + + // Items to display (filtered or all) + const displayItems = filteredItems ?? allTabItems; + + // Reset scroll when tab changes + useEffect(() => { + setActiveIndex(0); + setScrollOffset(0); + }, [activeTabIndex]); + + // Reset scroll when filtered items change + useEffect(() => { + setActiveIndex(0); + setScrollOffset(0); + }, [filteredItems]); + + // Search buffer + const searchBuffer = useSearchBuffer({ + initialText: '', + onChange: useCallback((text: string) => { + setSearchQuery(text); + }, []), + }); + + // Visible items + const visibleItems = displayItems.slice( + scrollOffset, + scrollOffset + effectiveMaxItemsToShow, + ); + const hasOverflow = displayItems.length > effectiveMaxItemsToShow; + + // Fixed height for the list area to prevent layout jumpiness in alternate + // buffer mode. Each item is ITEM_HEIGHT lines, plus 2 for scroll arrows. + const ITEM_HEIGHT = 2; + const listAreaHeight = effectiveMaxItemsToShow * ITEM_HEIGHT + 2; + + // Keyboard handling + useKeypress( + (key: Key) => { + // Tab cycling with left/right arrows or Tab + if ( + keyMatchers[Command.MOVE_LEFT](key) || + keyMatchers[Command.MOVE_RIGHT](key) || + key.name === 'tab' + ) { + const direction = + keyMatchers[Command.MOVE_LEFT](key) || + (key.name === 'tab' && key.shift) + ? -1 + : 1; + setActiveTabIndex( + (prev) => (prev + direction + TABS.length) % TABS.length, + ); + return; + } + + // Up/Down navigation (no wrap-around) + if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { + if (activeIndex > 0) { + const newIndex = activeIndex - 1; + setActiveIndex(newIndex); + if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + } + return; + } + if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { + if (activeIndex < displayItems.length - 1) { + const newIndex = activeIndex + 1; + setActiveIndex(newIndex); + if (newIndex >= scrollOffset + effectiveMaxItemsToShow) { + setScrollOffset(newIndex - effectiveMaxItemsToShow + 1); + } + } + return; + } + + // Escape - clear search first, then close + if (keyMatchers[Command.ESCAPE](key)) { + if (searchQuery) { + searchBuffer.setText(''); + setSearchQuery(''); + } else { + onClose(); + } + return; + } + + // Don't intercept other key matchers + }, + { isActive: true, priority: true }, + ); + + // Tab label + const tabLabel = activeTab.label.toLowerCase(); + const filteredCount = displayItems.length; + const totalCount = allTabItems.length; + const countText = + filteredItems !== null + ? `${filteredCount} of ${totalCount} ${activeTab.label} policies` + : `${totalCount} ${activeTab.label} ${totalCount === 1 ? 'policy' : 'policies'}`; + + return ( + + {/* Title & Tabs */} + + Policies: + + + + {/* Tab description */} + {showTabDescription && ( + + {activeTab.description} + + )} + + {/* Search box */} + + + + + {/* List — fixed height to prevent jumpiness in alternate buffer */} + + + + {hasOverflow && scrollOffset > 0 ? '▲' : ' '} + + + {displayItems.length === 0 ? ( + + + {searchQuery ? 'No policies match.' : `No ${tabLabel} policies.`} + + {!searchQuery && ( + + Learn more:{' '} + + https://geminicli.com/docs/reference/policy-engine/ + + + )} + + ) : ( + visibleItems.map((item, idx) => { + const globalIndex = idx + scrollOffset; + const isActive = activeIndex === globalIndex; + + // Line 1: Display name with optional constraint + // e.g. "Shell(git diff*)" or "all tools" + const toolPart = + item.toolDisplayName === 'all tools' ? ( + all tools + ) : ( + + {item.toolDisplayName} + {item.constraint !== undefined && ( + + ({item.constraint}) + + )} + + ); + + return ( + + + + {isActive ? '●' : ''} + + + + + {toolPart} + + {item.source && ( + + {item.source} + + )} + + + ); + }) + )} + + + {hasOverflow && + scrollOffset + effectiveMaxItemsToShow < displayItems.length + ? '▼' + : ' '} + + + + + {/* Footer */} + + {countText} + + {showHelpText && ( + + + (↑↓ navigate, type to search, Tab to cycle tabs, Esc close) + + + )} + + ); +} diff --git a/packages/cli/src/ui/utils/policyUtils.test.ts b/packages/cli/src/ui/utils/policyUtils.test.ts new file mode 100644 index 0000000000..0c24174a66 --- /dev/null +++ b/packages/cli/src/ui/utils/policyUtils.test.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { formatArgsPattern, buildPolicyListItems } from './policyUtils.js'; +import { PolicyDecision } from '@google/gemini-cli-core'; + +describe('formatArgsPattern', () => { + it('should return undefined for undefined argsPattern', () => { + expect(formatArgsPattern(undefined)).toBeUndefined(); + }); + + it('should parse commandPrefix pattern (git diff)', () => { + // buildArgsPatterns produces this for commandPrefix: "git diff" + const pattern = new RegExp('"command":"git\\ diff(?:[\\s"]|\\\\")'); + expect(formatArgsPattern(pattern)).toBe('git diff*'); + }); + + it('should parse commandPrefix with special chars (npm run test:ci)', () => { + // escapeRegex escapes the colon + const pattern = new RegExp( + '"command":"npm\\ run\\ test\\:ci(?:[\\s"]|\\\\")', + ); + expect(formatArgsPattern(pattern)).toBe('npm run test:ci*'); + }); + + it('should parse commandPrefix with single word (ls)', () => { + const pattern = new RegExp('"command":"ls(?:[\\s"]|\\\\")'); + expect(formatArgsPattern(pattern)).toBe('ls*'); + }); + + it('should parse commandRegex pattern', () => { + const pattern = new RegExp('"command":"npm run .*'); + expect(formatArgsPattern(pattern)).toBe('npm run .*'); + }); + + it('should parse file_path pattern', () => { + const pattern = new RegExp('"file_path":".*\\/plans\\/.*"'); + expect(formatArgsPattern(pattern)).toBe('path: .*\\/plans\\/.*'); + }); + + it('should return file_path fallback for unrecognized file_path shape', () => { + const pattern = new RegExp('"file_path"'); + expect(formatArgsPattern(pattern)).toBe('path: ...'); + }); + + it('should return raw source for unknown short regex', () => { + const pattern = new RegExp('something_unknown'); + expect(formatArgsPattern(pattern)).toBe('something_unknown'); + }); + + it('should truncate long unknown patterns', () => { + const longPattern = new RegExp('a'.repeat(50)); + const result = formatArgsPattern(longPattern); + expect(result).toBe('a'.repeat(40) + '...'); + }); +}); + +describe('buildPolicyListItems', () => { + const toolDisplayNames = new Map([ + ['run_shell_command', 'Shell'], + ['glob', 'FindFiles'], + ['read_file', 'ReadFile'], + ]); + + it('should filter by decision correctly', () => { + const rules = [ + { decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 10 }, + { decision: PolicyDecision.DENY, toolName: 'read_file', priority: 5 }, + { decision: PolicyDecision.ALLOW, toolName: 'read_file', priority: 3 }, + ]; + + const allowItems = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(allowItems).toHaveLength(2); + expect(allowItems[0].toolDisplayName).toBe('FindFiles'); + expect(allowItems[1].toolDisplayName).toBe('ReadFile'); + + const denyItems = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.DENY, + ); + expect(denyItems).toHaveLength(1); + expect(denyItems[0].toolDisplayName).toBe('ReadFile'); + }); + + it('should sort by priority descending', () => { + const rules = [ + { decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 1 }, + { + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + priority: 10, + }, + { decision: PolicyDecision.ALLOW, toolName: 'read_file', priority: 5 }, + ]; + + const items = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(items[0].toolDisplayName).toBe('Shell'); + expect(items[1].toolDisplayName).toBe('ReadFile'); + expect(items[2].toolDisplayName).toBe('FindFiles'); + }); + + it('should resolve display names from map', () => { + const rules = [ + { decision: PolicyDecision.ALLOW, toolName: 'run_shell_command' }, + { decision: PolicyDecision.ALLOW, toolName: 'unknown_tool' }, + { decision: PolicyDecision.ALLOW }, + ]; + + const items = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(items[0].toolDisplayName).toBe('Shell'); + expect(items[1].toolDisplayName).toBe('unknown_tool'); + expect(items[2].toolDisplayName).toBe('all tools'); + }); + + it('should include constraint in searchText', () => { + const rules = [ + { + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + argsPattern: new RegExp('"command":"git\\ diff(?:[\\s"]|\\\\")'), + }, + ]; + + const items = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(items[0].searchText).toContain('Shell'); + expect(items[0].searchText).toContain('git diff*'); + }); + + it('should include both display name and internal name in searchText', () => { + const rules = [ + { + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + }, + ]; + + const items = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(items[0].searchText).toContain('Shell'); + expect(items[0].searchText).toContain('run_shell_command'); + }); + + it('should format constraint from argsPattern', () => { + const rules = [ + { + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + argsPattern: new RegExp('"command":"git\\ show(?:[\\s"]|\\\\")'), + }, + ]; + + const items = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(items[0].constraint).toBe('git show*'); + }); + + it('should have undefined constraint when no argsPattern', () => { + const rules = [ + { decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 5 }, + ]; + + const items = buildPolicyListItems( + rules, + toolDisplayNames, + PolicyDecision.ALLOW, + ); + expect(items[0].constraint).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/ui/utils/policyUtils.ts b/packages/cli/src/ui/utils/policyUtils.ts new file mode 100644 index 0000000000..3f60812117 --- /dev/null +++ b/packages/cli/src/ui/utils/policyUtils.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { PolicyRule, PolicyDecision } from '@google/gemini-cli-core'; + +/** + * Represents a single item in the policies dialog list. + */ +export interface PolicyListItem { + /** Unique key for React rendering */ + key: string; + /** The original policy rule */ + rule: PolicyRule; + /** Uppercased decision string */ + decision: string; + /** Resolved display name (e.g. "Shell") or fallback to internal name */ + toolDisplayName: string; + /** Formatted constraint string for parenthetical display, or undefined */ + constraint: string | undefined; + /** Formatted priority string */ + priority: string; + /** rule.source ?? '' */ + source: string; + /** Concatenated searchable fields */ + searchText: string; +} + +/** + * Un-escapes a regex string that was escaped by `escapeRegex()` from + * packages/core/src/policy/utils.ts. + * + * escapeRegex escapes: [-[\]{}()*+?.,\\^$|#\s"] + * by prefixing each with a backslash. + */ +function unescapeRegex(escaped: string): string { + return escaped.replace(/\\(.)/g, '$1'); +} + +/** + * Formats an argsPattern regex into a human-readable constraint string. + * + * The policy engine compiles TOML rule fields (commandPrefix, commandRegex, + * argsPattern) into RegExp objects via buildArgsPatterns() in + * packages/core/src/policy/utils.ts. This function reverses that compilation + * back into readable text for display. + * + * Pattern formats (checked in order): + * + * 1. commandPrefix — `"command":"(?:[\s"]|\\")` → `*` + * 2. commandRegex — starts with `"command":"` → raw regex after the prefix + * 3. file_path — contains `"file_path":""` → `path: ` + * 4. Fallback — truncated raw regex source + * + * Returns undefined if there is no constraint (tool-only or wildcard rules). + */ +export function formatArgsPattern( + argsPattern: RegExp | undefined, +): string | undefined { + if (!argsPattern) { + return undefined; + } + + const source = argsPattern.source; + + // 1. commandPrefix: "command":"(?:[\s"]|\\")" + // The lookahead ensures the prefix is word-bounded in JSON. + const prefixMatch = source.match(/^"command":"(.+?)\(\?:\[\\s"\]\|\\\\"?\)$/); + if (prefixMatch) { + return unescapeRegex(prefixMatch[1]) + '*'; + } + + // 2. commandRegex: starts with "command":" + if (source.startsWith('"command":"')) { + const regex = source.slice('"command":"'.length); + return regex; + } + + // 3. file_path pattern + if (source.includes('"file_path"')) { + const pathMatch = source.match(/"file_path":"(.+?)"/); + if (pathMatch) { + return `path: ${pathMatch[1]}`; + } + return 'path: ...'; + } + + // 4. Fallback: truncated raw regex + const maxLen = 40; + if (source.length > maxLen) { + return source.substring(0, maxLen) + '...'; + } + return source; +} + +/** + * Builds a list of PolicyListItems from rules, filtered by decision and sorted + * by priority descending. + */ +export function buildPolicyListItems( + rules: readonly PolicyRule[], + toolDisplayNames: Map, + decision: PolicyDecision, +): PolicyListItem[] { + return rules + .map((rule, index) => ({ rule, index })) + .filter(({ rule }) => rule.decision === decision) + .sort((a, b) => (b.rule.priority ?? 0) - (a.rule.priority ?? 0)) + .map(({ rule, index }) => { + const toolDisplayName = rule.toolName + ? (toolDisplayNames.get(rule.toolName) ?? rule.toolName) + : 'all tools'; + const constraint = formatArgsPattern(rule.argsPattern); + const priority = String(rule.priority ?? 0); + const source = rule.source ?? ''; + + const searchText = [toolDisplayName, rule.toolName, constraint, source] + .filter(Boolean) + .join(' '); + + return { + key: `policy-${index}`, + rule, + decision: rule.decision.toUpperCase(), + toolDisplayName, + constraint, + priority, + source, + searchText, + }; + }); +}