mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-17 05:20:23 -07:00
feat(ui): add policies dialog
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user