feat(ui): add policies dialog

This commit is contained in:
Jack Wotherspoon
2026-03-14 18:21:25 +01:00
parent 6c3a90645a
commit 2b96590e94
5 changed files with 931 additions and 146 deletions
@@ -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<typeof createMockCommandContext>;
@@ -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<string, string> = {
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),
);
});
});
});
+68 -92
View File
@@ -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<string, string> {
const toolDisplayNames = new Map<string, string>();
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<SlashCommand['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 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],
};
@@ -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<string, string>;
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<PolicyListItem[] | null>(
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<PolicyDecision, PolicyListItem[]>();
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<PolicyDecision, AsyncFzf<PolicyListItem[]>>();
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 (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{/* Title & Tabs */}
<Box marginX={1} flexDirection="row">
<Text bold>Policies: </Text>
<TabHeader
tabs={policyTabs}
currentIndex={activeTabIndex}
showStatusIcons={false}
/>
</Box>
{/* Tab description */}
{showTabDescription && (
<Box marginX={1}>
<Text color={theme.text.secondary}>{activeTab.description}</Text>
</Box>
)}
{/* Search box */}
<Box
borderStyle="round"
borderColor={theme.ui.focus}
paddingX={1}
height={3}
>
<TextInput
focus={true}
buffer={searchBuffer}
placeholder="Search to filter..."
/>
</Box>
{/* List — fixed height to prevent jumpiness in alternate buffer */}
<Box flexDirection="column" marginTop={1} height={listAreaHeight}>
<Box marginX={1}>
<Text color={theme.text.secondary}>
{hasOverflow && scrollOffset > 0 ? '▲' : ' '}
</Text>
</Box>
{displayItems.length === 0 ? (
<Box marginX={1} flexDirection="column">
<Text color={theme.text.secondary}>
{searchQuery ? 'No policies match.' : `No ${tabLabel} policies.`}
</Text>
{!searchQuery && (
<Text wrap="wrap">
Learn more:{' '}
<Text color={theme.text.link}>
https://geminicli.com/docs/reference/policy-engine/
</Text>
</Text>
)}
</Box>
) : (
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' ? (
<Text color={theme.text.secondary}>all tools</Text>
) : (
<Text>
{item.toolDisplayName}
{item.constraint !== undefined && (
<Text color={theme.text.secondary}>
({item.constraint})
</Text>
)}
</Text>
);
return (
<Box
key={item.key}
marginX={1}
flexDirection="row"
alignItems="flex-start"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<Box minWidth={2} flexShrink={0}>
<Text
color={isActive ? theme.ui.focus : theme.text.secondary}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box flexDirection="column" minWidth={0}>
<Text
color={isActive ? theme.ui.focus : theme.text.primary}
wrap="truncate"
>
{toolPart}
</Text>
{item.source && (
<Text color={theme.text.secondary} wrap="truncate">
{item.source}
</Text>
)}
</Box>
</Box>
);
})
)}
<Box marginX={1} marginTop={0}>
<Text color={theme.text.secondary}>
{hasOverflow &&
scrollOffset + effectiveMaxItemsToShow < displayItems.length
? '▼'
: ' '}
</Text>
</Box>
</Box>
{/* Footer */}
<Box marginX={1} marginTop={1}>
<Text color={theme.text.secondary}>{countText}</Text>
</Box>
{showHelpText && (
<Box marginX={1}>
<Text color={theme.text.secondary}>
( navigate, type to search, Tab to cycle tabs, Esc close)
</Text>
</Box>
)}
</Box>
);
}
@@ -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();
});
});
+134
View File
@@ -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":"<escaped>(?:[\s"]|\\")` → `<prefix>*`
* 2. commandRegex — starts with `"command":"` → raw regex after the prefix
* 3. file_path — contains `"file_path":"<path>"` → `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":"<escaped-prefix>(?:[\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<string, string>,
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,
};
});
}