Compare commits

...

20 Commits

Author SHA1 Message Date
Jack Wotherspoon c618fcb2be Merge branch 'main' into gemini-cli-policies-ui 2026-03-31 16:00:48 -04:00
Jack Wotherspoon f518fd6004 fix(cli): fix toolName type and resolve '*' to 'all tools' in policies dialog 2026-03-31 16:00:02 -04:00
Jack Wotherspoon d5da95eb5c chore: update license year for policiesCommand.tsx 2026-03-31 15:54:47 -04:00
Jack Wotherspoon cb25b5f7c3 Merge branch 'main' into gemini-cli-policies-ui 2026-03-31 15:54:38 -04:00
Jack Wotherspoon cdd0814bdf Merge branch 'main' into gemini-cli-policies-ui 2026-03-31 15:52:21 -04:00
Jack Wotherspoon bda7a9a625 Merge remote-tracking branch 'origin/main' into gemini-cli-policies-ui 2026-03-31 15:51:30 -04:00
Jack Wotherspoon 44f6a0fafa Merge branch 'main' into gemini-cli-policies-ui 2026-03-20 09:55:56 -04:00
Jack Wotherspoon 326edee741 Merge remote-tracking branch 'origin/main' into gemini-cli-policies-ui
# Conflicts:
#	packages/cli/src/ui/commands/policiesCommand.test.ts
#	packages/cli/src/ui/commands/policiesCommand.ts
#	packages/core/src/policy/config.ts
#	packages/core/src/policy/toml-loader.ts
2026-03-20 09:22:37 -04:00
Jack Wotherspoon bfb13b4fc3 chore: remove one line 2026-03-14 21:50:55 +01:00
Jack Wotherspoon 27a56f4f7a chore: use reducer 2026-03-14 21:45:03 +01:00
Jack Wotherspoon 90bdba9059 Merge branch 'main' into gemini-cli-policies-ui 2026-03-14 21:30:40 +01:00
Jack Wotherspoon e1febb6849 chore: minor nits 2026-03-14 21:29:43 +01:00
Jack Wotherspoon 6a4f022a4f chore: update tests 2026-03-14 20:46:48 +01:00
Jack Wotherspoon 4204813895 chore: remove reverse engineering of arg pattern 2026-03-14 20:44:42 +01:00
Jack Wotherspoon 3de096b444 chore: update test 2026-03-14 20:18:50 +01:00
Jack Wotherspoon 8faed58289 chore: update test 2026-03-14 20:02:35 +01:00
Jack Wotherspoon 96cd5b7c5a chore: update 2026-03-14 19:53:04 +01:00
Jack Wotherspoon 480ac50204 Merge branch 'main' into gemini-cli-policies-ui 2026-03-14 19:13:11 +01:00
Jack Wotherspoon 7ff4402dbd chore: add tests 2026-03-14 18:35:15 +01:00
Jack Wotherspoon 2b96590e94 feat(ui): add policies dialog 2026-03-14 18:21:25 +01:00
15 changed files with 1565 additions and 367 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 { PolicyDecision, type AgentLoopContext } from '@google/gemini-cli-core';
describe('policiesCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
@@ -27,15 +23,17 @@ describe('policiesCommand', () => {
expect(policiesCommand.description).toBe('Manage policies');
expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN);
expect(policiesCommand.subCommands).toHaveLength(1);
expect(policiesCommand.subCommands![0].name).toBe('list');
expect(policiesCommand.subCommands?.[0].name).toBe('list');
});
describe('list subcommand', () => {
it('should show error if config is missing', async () => {
mockContext.services.agentContext = null;
const listCommand = policiesCommand.subCommands![0];
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
await listCommand.action!(mockContext, '');
await listCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -50,26 +48,75 @@ describe('policiesCommand', () => {
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue([]),
};
mockContext.services.agentContext = {
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
get config() {
return this;
},
} as unknown as Config;
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue(undefined),
}),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
await listCommand.action(mockContext, '');
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),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue(undefined),
}),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
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,
@@ -88,89 +135,125 @@ describe('policiesCommand', () => {
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue(mockRules),
};
mockContext.services.agentContext = {
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(undefined),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
get config() {
return this;
},
} as unknown as Config;
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('**Active Policies**'),
}),
expect.any(Number),
);
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(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 (combined with normal 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),
};
mockContext.services.agentContext = {
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;
}),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
get config() {
return this;
},
} as unknown as Config;
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
const content = (call[0] as { text: string }).text;
const result = await listCommand.action(mockContext, '');
// Plan-only rules appear under Plan Mode section
expect(content).toContain(
'### Plan Mode Policies (combined with normal mode policies)',
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');
});
});
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),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
if (!policiesCommand.action)
throw new Error('policiesCommand action missing');
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.agentContext = null;
if (!policiesCommand.action)
throw new Error('policiesCommand action missing');
await policiesCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Error: Config not available.',
}),
expect.any(Number),
);
// 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]');
});
});
});
@@ -1,124 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
interface CategorizedRules {
normal: PolicyRule[];
autoEdit: PolicyRule[];
yolo: PolicyRule[];
plan: PolicyRule[];
}
const categorizeRulesByMode = (
rules: readonly PolicyRule[],
): CategorizedRules => {
const result: CategorizedRules = {
normal: [],
autoEdit: [],
yolo: [],
plan: [],
};
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 agentContext = context.services.agentContext;
const config = agentContext?.config;
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 (combined with normal mode policies)',
uniquePlan,
);
context.ui.addItem(
{
type: MessageType.INFO,
text: content,
},
Date.now(),
);
},
};
export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listPoliciesCommand],
};
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
import { PoliciesDialog } from '../components/PoliciesDialog.js';
function buildToolDisplayNames(
rules: readonly PolicyRule[],
config: Config,
): 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 policiesDialogAction: NonNullable<SlashCommand['action']> = async (
context,
) => {
const { agentContext } = context.services;
const config = agentContext?.config;
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: (
<PoliciesDialog
rules={rules}
toolDisplayNames={toolDisplayNames}
onClose={() => context.ui.removeComponent()}
/>
),
};
};
const listPoliciesCommand: SlashCommand = {
name: 'list',
description: 'List all active policies grouped by mode',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: policiesDialogAction,
};
export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: policiesDialogAction,
subCommands: [listPoliciesCommand],
};
@@ -0,0 +1,374 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PoliciesDialog } from './PoliciesDialog.js';
import { PolicyDecision } from '@google/gemini-cli-core';
import type { PolicyRule } from '@google/gemini-cli-core';
function makeRule(
overrides: Partial<PolicyRule> & { decision: PolicyDecision },
): PolicyRule {
return {
toolName: undefined,
priority: 0,
source: undefined,
argsPattern: undefined,
constraintDisplay: undefined,
...overrides,
} as PolicyRule;
}
// Realistic user/workspace/extension policies (default rules are filtered
// out before reaching the dialog — see policiesCommand.ts).
const ALLOW_RULES: PolicyRule[] = [
makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
priority: 4.1,
source: 'User: allowed-tools.toml',
constraintDisplay: 'git show*',
}),
makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
priority: 4.1,
source: 'User: allowed-tools.toml',
constraintDisplay: 'git diff*',
}),
makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
priority: 4.0,
source: 'Workspace: .gemini/settings.json',
constraintDisplay: 'npm test*',
}),
];
const ASK_RULES: PolicyRule[] = [
makeRule({
decision: PolicyDecision.ASK_USER,
toolName: 'write_file',
priority: 3,
source: 'Workspace: .gemini/policies/write-guard.toml',
}),
makeRule({
decision: PolicyDecision.ASK_USER,
toolName: 'run_shell_command',
priority: 2,
source: 'Workspace: .gemini/policies/shell.toml',
}),
];
const DENY_RULES: PolicyRule[] = [
makeRule({
decision: PolicyDecision.DENY,
toolName: 'run_shell_command',
priority: 10,
source: 'Admin: admin-policies.toml',
constraintDisplay: 'rm -rf*',
}),
];
const ALL_RULES = [...ALLOW_RULES, ...ASK_RULES, ...DENY_RULES];
const TOOL_DISPLAY_NAMES = new Map([
['run_shell_command', 'Shell'],
['write_file', 'WriteFile'],
]);
describe('PoliciesDialog', () => {
let onClose: () => void;
beforeEach(() => {
onClose = vi.fn();
});
it('renders with Allow tab active by default', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('Policies');
expect(output).toContain('Allow');
// Should show resolved display names, not internal names
expect(output).toContain('Shell');
expect(output).not.toContain('run_shell_command');
});
it('shows formatted shell command constraints', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('git show*');
expect(output).toContain('git diff*');
expect(output).toContain('npm test*');
});
it('displays policy source on each item', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('User: allowed-tools.toml');
expect(output).toContain('Workspace: .gemini/settings.json');
});
it('displays correct count for Allow tab', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('3 Allow policies');
});
it('switches to Ask tab with right arrow', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
act(() => {
stdin.write('\x1B[C'); // Right arrow
});
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('2 Ask policies');
});
});
it('switches to Deny tab and shows deny rules', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// Right arrow twice: Allow → Ask → Deny
act(() => {
stdin.write('\x1B[C');
});
act(() => {
stdin.write('\x1B[C');
});
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('rm -rf*');
expect(output).toContain('Admin: admin-policies.toml');
expect(output).toContain('1 Deny policy');
});
});
it('wraps tabs with left arrow from first tab', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// Left from Allow wraps to Deny
act(() => {
stdin.write('\x1B[D');
});
await waitFor(() => {
expect(lastFrame()).toContain('1 Deny policy');
});
});
it('shows empty state when a tab has no rules', async () => {
// Only allow rules — Ask and Deny tabs will be empty
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALLOW_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// Navigate to Ask tab
act(() => {
stdin.write('\x1B[C');
});
await waitFor(() => {
expect(lastFrame()).toContain('No ask policies.');
});
});
it('navigates list items with up/down arrows', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// First item is active (highest priority first)
expect(lastFrame()).toContain('●');
// Move down
act(() => {
stdin.write('\x1B[B');
});
await waitFor(() => {
// Active indicator should still be present on a different item
expect(lastFrame()).toContain('●');
});
});
it('closes on Escape when search is empty', async () => {
const { stdin } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
act(() => {
stdin.write('\x1B');
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('shows "all tools" for wildcard rules (no toolName)', async () => {
const wildcardRule = makeRule({
decision: PolicyDecision.ALLOW,
priority: 1,
source: 'Workspace: .gemini/policies/global.toml',
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={[wildcardRule]}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('all tools');
});
it('falls back to internal name for unmapped tools (e.g. MCP)', async () => {
const mcpRule = makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'mcp_my-server_search',
priority: 5,
source: 'User: mcp-policies.toml',
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={[mcpRule]}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// No display name mapping exists, so internal name is shown
expect(lastFrame()).toContain('mcp_my-server_search');
});
it('renders with no rules at all (empty allow tab)', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={[]}
toolDisplayNames={new Map()}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('No allow policies.');
});
it('sorts rules by priority descending within a tab', async () => {
// All three allow rules have priorities 4.1, 4.1, 4.0
// The 4.1 rules (git show, git diff) should appear before 4.0 (npm test)
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
const gitShowIdx = output.indexOf('git show*');
const npmTestIdx = output.indexOf('npm test*');
expect(gitShowIdx).toBeLessThan(npmTestIdx);
});
});
@@ -0,0 +1,468 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useMemo, useEffect, useCallback, useReducer } from 'react';
import { Box, Text } from 'ink';
import { AsyncFzf, type FzfResultItem } 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 { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import {
buildPolicyListItems,
type PolicyListItem,
} from '../utils/policyUtils.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
const ITEM_HEIGHT = 2;
interface NavigationState {
activeTabIndex: number;
activeIndex: number;
scrollOffset: number;
}
type NavigationAction =
| { type: 'MOVE_UP'; maxItemsToShow: number }
| { type: 'MOVE_DOWN'; maxItemsToShow: number; totalItems: number }
| { type: 'CYCLE_TAB'; direction: number; numTabs: number }
| { type: 'RESET_SCROLL' };
function navigationReducer(
state: NavigationState,
action: NavigationAction,
): NavigationState {
switch (action.type) {
case 'MOVE_UP': {
if (state.activeIndex <= 0) return state;
const nextIndex = state.activeIndex - 1;
return {
...state,
activeIndex: nextIndex,
scrollOffset:
nextIndex < state.scrollOffset ? nextIndex : state.scrollOffset,
};
}
case 'MOVE_DOWN': {
if (state.activeIndex >= action.totalItems - 1) return state;
const nextIndex = state.activeIndex + 1;
return {
...state,
activeIndex: nextIndex,
scrollOffset:
nextIndex >= state.scrollOffset + action.maxItemsToShow
? nextIndex - action.maxItemsToShow + 1
: state.scrollOffset,
};
}
case 'CYCLE_TAB': {
return {
...state,
activeTabIndex:
(state.activeTabIndex + action.direction + action.numTabs) %
action.numTabs,
activeIndex: 0,
scrollOffset: 0,
};
}
case 'RESET_SCROLL': {
return {
...state,
activeIndex: 0,
scrollOffset: 0,
};
}
default:
return state;
}
}
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;
export function PoliciesDialog({
rules,
toolDisplayNames,
onClose,
}: PoliciesDialogProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { terminalHeight, terminalWidth, staticExtraHeight, constrainHeight } =
useUIState();
const [{ activeTabIndex, activeIndex, scrollOffset }, dispatch] = useReducer(
navigationReducer,
{ activeTabIndex: 0, activeIndex: 0, scrollOffset: 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 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;
const matched: PolicyListItem[] = [];
results.forEach((r: FzfResultItem<PolicyListItem>) => {
matched.push(r.item);
});
setFilteredItems(matched);
};
void doSearch();
return () => {
active = false;
};
}, [searchQuery, activeTab.decision, fzfInstances]);
// Items to display (filtered or all)
const displayItems = filteredItems ?? allTabItems;
// Reset scroll when filtered items change
useEffect(() => {
dispatch({ type: 'RESET_SCROLL' });
}, [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 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;
dispatch({ type: 'CYCLE_TAB', direction, numTabs: TABS.length });
return;
}
// Up/Down navigation (no wrap-around)
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({ type: 'MOVE_UP', maxItemsToShow: effectiveMaxItemsToShow });
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({
type: 'MOVE_DOWN',
maxItemsToShow: effectiveMaxItemsToShow,
totalItems: displayItems.length,
});
return;
}
// Escape - clear search first, then close
if (keyMatchers[Command.ESCAPE](key)) {
if (searchQuery) {
searchBuffer.setText('');
} 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}>
(Use to navigate, / or Tab to cycle, Esc to close)
</Text>
</Box>
)}
</Box>
);
}
@@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PoliciesDialog > renders with Allow tab active by default 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Policies: ← Allow (3) │ Ask (2) │ Deny (1) → │
│ │
│ Tools that run automatically without confirmation. │
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter... │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ ● Shell(git show*) │
│ User: allowed-tools.toml │
│ Shell(git diff*) │
│ User: allowed-tools.toml │
│ Shell(npm test*) │
│ Workspace: .gemini/settings.json │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ 3 Allow policies │
│ (Use ↑↓ to navigate, ←/→ or Tab to cycle, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { buildPolicyListItems } from './policyUtils.js';
import { PolicyDecision } from '@google/gemini-cli-core';
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, toolName: '*' },
];
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',
constraintDisplay: 'git diff*',
},
];
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 take constraint from constraintDisplay', () => {
const rules = [
{
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
constraintDisplay: 'git show*',
},
];
const items = buildPolicyListItems(
rules,
toolDisplayNames,
PolicyDecision.ALLOW,
);
expect(items[0].constraint).toBe('git show*');
});
it('should have undefined constraint when no constraintDisplay', () => {
const rules = [
{ decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 5 },
];
const items = buildPolicyListItems(
rules,
toolDisplayNames,
PolicyDecision.ALLOW,
);
expect(items[0].constraint).toBeUndefined();
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2026 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;
/** Resolved display name (e.g. "Shell") or fallback to internal name */
toolDisplayName: string;
/** Formatted constraint string for parenthetical display, or undefined */
constraint: string | undefined;
/** rule.source ?? '' */
source: string;
/** Concatenated searchable fields */
searchText: string;
}
/**
* 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 || rule.toolName === '*'
? 'all tools'
: (toolDisplayNames.get(rule.toolName) ?? rule.toolName);
const constraint = rule.constraintDisplay;
const source = rule.source ?? '';
const searchText = [toolDisplayName, rule.toolName, constraint, source]
.filter(Boolean)
.join(' ');
return {
key: `policy-${index}`,
rule,
toolDisplayName,
constraint,
source,
searchText,
};
});
}
+4 -2
View File
@@ -449,13 +449,14 @@ export async function createPolicyEngineConfig(
// Treat args as a command prefix for shell tool
if (toolName === SHELL_TOOL_NAME) {
const patterns = buildArgsPatterns(undefined, args);
for (const pattern of patterns) {
for (const { pattern, display: constraintDisplay } of patterns) {
if (pattern) {
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
argsPattern: new RegExp(pattern),
constraintDisplay,
source: 'Settings (Tools Allowed)',
});
}
@@ -575,7 +576,7 @@ export function createPolicyUpdater(
return;
}
for (const pattern of patterns) {
for (const { pattern, display: constraintDisplay } of patterns) {
if (pattern) {
// Note: patterns from buildArgsPatterns are derived from escapeRegex,
// which is safe and won't contain ReDoS patterns.
@@ -584,6 +585,7 @@ export function createPolicyUpdater(
decision: PolicyDecision.ALLOW,
priority,
argsPattern: new RegExp(pattern),
constraintDisplay,
mcpName: message.mcpName,
source: 'Dynamic (Confirmed)',
allowRedirection: message.allowRedirection,
@@ -786,7 +786,7 @@ describe('PolicyEngine', () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(patterns[0]!),
argsPattern: new RegExp(patterns[0].pattern!),
decision: PolicyDecision.ALLOW,
},
];
+11 -11
View File
@@ -87,7 +87,7 @@ describe('Shell Safety Policy', () => {
const argsPatterns = buildArgsPatterns(undefined, prefix, undefined);
// Since buildArgsPatterns returns array of patterns (strings), we pick the first one
// and compile it.
const argsPattern = new RegExp(argsPatterns[0]!);
const argsPattern = new RegExp(argsPatterns[0].pattern!);
return new PolicyEngine({
rules: [
@@ -201,13 +201,13 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsEcho[0]!),
argsPattern: new RegExp(argsPatternsEcho[0].pattern!),
decision: PolicyDecision.ALLOW,
priority: 2,
},
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsGit[0]!),
argsPattern: new RegExp(argsPatternsGit[0].pattern!),
decision: PolicyDecision.ALLOW,
priority: 2,
},
@@ -287,14 +287,14 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsEcho[0]!),
argsPattern: new RegExp(argsPatternsEcho[0].pattern!),
decision: PolicyDecision.ALLOW,
priority: 2,
},
{
toolName: 'run_shell_command',
// Matches "git" at start of *subcommand*
argsPattern: new RegExp(argsPatternsGit[0]!),
argsPattern: new RegExp(argsPatternsGit[0].pattern!),
decision: PolicyDecision.ALLOW,
priority: 2,
},
@@ -332,7 +332,7 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsGitLog[0]!),
argsPattern: new RegExp(argsPatternsGitLog[0].pattern!),
decision: PolicyDecision.ALLOW,
priority: 2,
allowRedirection: true,
@@ -375,7 +375,7 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsPush[0]!),
argsPattern: new RegExp(argsPatternsPush[0].pattern!),
decision: PolicyDecision.DENY,
priority: 2,
},
@@ -406,7 +406,7 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsGitStatus[0]!),
argsPattern: new RegExp(argsPatternsGitStatus[0].pattern!),
decision: PolicyDecision.ALLOW,
priority: 2,
name: 'allow_git_status_rule', // Give a name to easily identify
@@ -443,7 +443,7 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsAnotherUnknown[0]!),
argsPattern: new RegExp(argsPatternsAnotherUnknown[0].pattern!),
decision: PolicyDecision.ASK_USER,
priority: 2,
name: 'ask_another_unknown_command_rule',
@@ -486,14 +486,14 @@ describe('Shell Safety Policy', () => {
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsAsk1[0]!),
argsPattern: new RegExp(argsPatternsAsk1[0].pattern!),
decision: PolicyDecision.ASK_USER,
priority: 2,
name: 'ask_rule_1',
},
{
toolName: 'run_shell_command',
argsPattern: new RegExp(argsPatternsAsk2[0]!),
argsPattern: new RegExp(argsPatternsAsk2[0].pattern!),
decision: PolicyDecision.ASK_USER,
priority: 2,
name: 'ask_rule_2',
+134 -128
View File
@@ -461,83 +461,86 @@ export async function loadPoliciesFromToml(
);
// For each argsPattern, expand toolName arrays
return argsPatterns.flatMap((argsPattern) => {
const toolNames: string[] = Array.isArray(rule.toolName)
? rule.toolName
: [rule.toolName];
return argsPatterns.flatMap(
({ pattern: argsPattern, display: constraintDisplay }) => {
const toolNames: string[] = Array.isArray(rule.toolName)
? rule.toolName
: [rule.toolName];
// Create a policy rule for each tool name
return toolNames.map((toolName) => {
let effectiveToolName: string = toolName;
const mcpName = rule.mcpName;
// Create a policy rule for each tool name
return toolNames.map((toolName) => {
let effectiveToolName: string = toolName;
const mcpName = rule.mcpName;
if (mcpName) {
// TODO(mcp): Decouple mcpName rules from FQN string parsing
// to support underscores in server aliases natively. Leaving
// mcpName and toolName separate here and relying on metadata
// during policy evaluation will avoid underscore splitting bugs.
// See: https://github.com/google-gemini/gemini-cli/issues/21727
effectiveToolName = formatMcpToolName(
mcpName,
effectiveToolName,
);
}
const policyRule: PolicyRule = {
toolName: effectiveToolName,
subagent: rule.subagent,
mcpName: rule.mcpName,
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
interactive: rule.interactive,
toolAnnotations: rule.toolAnnotations,
allowRedirection:
rule.allowRedirection ?? rule.allow_redirection,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
denyMessage: rule.denyMessage ?? rule.deny_message,
};
// Compile regex pattern
if (argsPattern) {
try {
new RegExp(argsPattern);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message: 'Invalid regex pattern',
details: `Pattern: ${argsPattern}\nError: ${error.message}`,
suggestion:
'Check regex syntax for errors like unmatched brackets or invalid escape sequences',
});
return null;
if (mcpName) {
// TODO(mcp): Decouple mcpName rules from FQN string parsing
// to support underscores in server aliases natively. Leaving
// mcpName and toolName separate here and relying on metadata
// during policy evaluation will avoid underscore splitting bugs.
// See: https://github.com/google-gemini/gemini-cli/issues/21727
effectiveToolName = formatMcpToolName(
mcpName,
effectiveToolName,
);
}
if (!isSafeRegExp(argsPattern)) {
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message: 'Unsafe regex pattern (potential ReDoS)',
details: `Pattern: ${argsPattern}`,
suggestion:
'Avoid nested quantifiers or extremely long patterns',
});
return null;
const policyRule: PolicyRule = {
toolName: effectiveToolName,
subagent: rule.subagent,
mcpName: rule.mcpName,
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
interactive: rule.interactive,
toolAnnotations: rule.toolAnnotations,
allowRedirection:
rule.allowRedirection ?? rule.allow_redirection,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
denyMessage: rule.denyMessage ?? rule.deny_message,
constraintDisplay,
};
// Compile regex pattern
if (argsPattern) {
try {
new RegExp(argsPattern);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message: 'Invalid regex pattern',
details: `Pattern: ${argsPattern}\nError: ${error.message}`,
suggestion:
'Check regex syntax for errors like unmatched brackets or invalid escape sequences',
});
return null;
}
if (!isSafeRegExp(argsPattern)) {
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message: 'Unsafe regex pattern (potential ReDoS)',
details: `Pattern: ${argsPattern}`,
suggestion:
'Avoid nested quantifiers or extremely long patterns',
});
return null;
}
policyRule.argsPattern = new RegExp(argsPattern);
}
policyRule.argsPattern = new RegExp(argsPattern);
}
return policyRule;
});
});
return policyRule;
});
},
);
})
.filter((rule): rule is PolicyRule => rule !== null);
@@ -598,68 +601,71 @@ export async function loadPoliciesFromToml(
checker.commandRegex,
);
return argsPatterns.flatMap((argsPattern) => {
const toolNames: string[] = Array.isArray(checker.toolName)
? checker.toolName
: [checker.toolName];
return argsPatterns.flatMap(
({ pattern: argsPattern, display: constraintDisplay }) => {
const toolNames: string[] = Array.isArray(checker.toolName)
? checker.toolName
: [checker.toolName];
return toolNames.map((toolName) => {
let effectiveToolName: string;
if (checker.mcpName && toolName !== '*') {
effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_${toolName}`;
} else if (checker.mcpName) {
effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_*`;
} else {
effectiveToolName = toolName;
}
return toolNames.map((toolName) => {
let effectiveToolName: string;
if (checker.mcpName && toolName !== '*') {
effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_${toolName}`;
} else if (checker.mcpName) {
effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_*`;
} else {
effectiveToolName = toolName;
}
const safetyCheckerRule: SafetyCheckerRule = {
toolName: effectiveToolName,
mcpName: checker.mcpName,
priority: transformPriority(checker.priority, tier),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
checker: checker.checker as SafetyCheckerConfig,
modes: checker.modes,
toolAnnotations: checker.toolAnnotations,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
};
if (argsPattern) {
try {
new RegExp(argsPattern);
} catch (e) {
const safetyCheckerRule: SafetyCheckerRule = {
toolName: effectiveToolName,
mcpName: checker.mcpName,
priority: transformPriority(checker.priority, tier),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message: 'Invalid regex pattern in safety checker',
details: `Pattern: ${argsPattern}\nError: ${error.message}`,
});
return null;
checker: checker.checker as SafetyCheckerConfig,
modes: checker.modes,
toolAnnotations: checker.toolAnnotations,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
constraintDisplay,
};
if (argsPattern) {
try {
new RegExp(argsPattern);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message: 'Invalid regex pattern in safety checker',
details: `Pattern: ${argsPattern}\nError: ${error.message}`,
});
return null;
}
if (!isSafeRegExp(argsPattern)) {
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message:
'Unsafe regex pattern in safety checker (potential ReDoS)',
details: `Pattern: ${argsPattern}`,
});
return null;
}
safetyCheckerRule.argsPattern = new RegExp(argsPattern);
}
if (!isSafeRegExp(argsPattern)) {
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'regex_compilation',
message:
'Unsafe regex pattern in safety checker (potential ReDoS)',
details: `Pattern: ${argsPattern}`,
});
return null;
}
safetyCheckerRule.argsPattern = new RegExp(argsPattern);
}
return safetyCheckerRule;
});
});
return safetyCheckerRule;
});
},
);
})
.filter((checker): checker is SafetyCheckerRule => checker !== null);
+11
View File
@@ -130,6 +130,12 @@ export interface PolicyRule {
*/
argsPattern?: RegExp;
/**
* A human-readable display string representing the constraint (e.g., 'git diff*').
* Used for UI presentation to avoid reverse-engineering the compiled `argsPattern` regex.
*/
constraintDisplay?: string;
/**
* Metadata annotations provided by the tool (e.g. readOnlyHint).
* All keys and values in this record must match the tool's annotations.
@@ -198,6 +204,11 @@ export interface SafetyCheckerRule {
*/
argsPattern?: RegExp;
/**
* A human-readable display string representing the constraint.
*/
constraintDisplay?: string;
/**
* Metadata annotations provided by the tool (e.g. readOnlyHint).
* All keys and values in this record must match the tool's annotations.
+32 -14
View File
@@ -64,62 +64,80 @@ describe('policy/utils', () => {
describe('buildArgsPatterns', () => {
it('should return argsPattern if provided and no commandPrefix/regex', () => {
const result = buildArgsPatterns('my-pattern', undefined, undefined);
expect(result).toEqual(['my-pattern']);
expect(result).toEqual([
{ pattern: 'my-pattern', display: 'my-pattern' },
]);
});
it('should build pattern from a single commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'ls', undefined);
expect(result).toEqual(['\\"command\\":\\"ls(?:[\\s"]|\\\\")']);
expect(result).toEqual([
{ pattern: '\\"command\\":\\"ls(?:[\\s"]|\\\\")', display: 'ls*' },
]);
});
it('should build patterns from an array of commandPrefixes', () => {
const result = buildArgsPatterns(undefined, ['echo', 'ls'], undefined);
expect(result).toEqual([
'\\"command\\":\\"echo(?:[\\s"]|\\\\")',
'\\"command\\":\\"ls(?:[\\s"]|\\\\")',
{ pattern: '\\"command\\":\\"echo(?:[\\s"]|\\\\")', display: 'echo*' },
{ pattern: '\\"command\\":\\"ls(?:[\\s"]|\\\\")', display: 'ls*' },
]);
});
it('should build pattern from commandRegex', () => {
const result = buildArgsPatterns(undefined, undefined, 'rm -rf .*');
expect(result).toEqual(['"command":"rm -rf .*']);
expect(result).toEqual([
{ pattern: '"command":"rm -rf .*', display: 'rm -rf .*' },
]);
});
it('should prioritize commandPrefix over commandRegex and argsPattern', () => {
const result = buildArgsPatterns('raw', 'prefix', 'regex');
expect(result).toEqual(['\\"command\\":\\"prefix(?:[\\s"]|\\\\")']);
expect(result).toEqual([
{
pattern: '\\"command\\":\\"prefix(?:[\\s"]|\\\\")',
display: 'prefix*',
},
]);
});
it('should prioritize commandRegex over argsPattern if no commandPrefix', () => {
const result = buildArgsPatterns('raw', undefined, 'regex');
expect(result).toEqual(['"command":"regex']);
expect(result).toEqual([
{ pattern: '"command":"regex', display: 'regex' },
]);
});
it('should escape characters in commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'git checkout -b', undefined);
expect(result).toEqual([
'\\"command\\":\\"git\\ checkout\\ \\-b(?:[\\s"]|\\\\")',
{
pattern: '\\"command\\":\\"git\\ checkout\\ \\-b(?:[\\s"]|\\\\")',
display: 'git checkout -b*',
},
]);
});
it('should correctly escape quotes in commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'git "fix"', undefined);
expect(result).toEqual([
// eslint-disable-next-line no-useless-escape
'\\\"command\\\":\\\"git\\ \\\\\\\"fix\\\\\\\"(?:[\\s\"]|\\\\\")',
{
pattern: '\\"command\\":\\"git\\ \\\\\\"fix\\\\\\"(?:[\\s"]|\\\\")',
display: 'git "fix"*',
},
]);
});
it('should handle undefined correctly when no inputs are provided', () => {
const result = buildArgsPatterns(undefined, undefined, undefined);
expect(result).toEqual([undefined]);
expect(result).toEqual([{ pattern: undefined, display: undefined }]);
});
it('should match prefixes followed by JSON escaped quotes', () => {
// Testing the security fix logic: allowing "echo \"foo\""
const prefix = 'echo ';
const patterns = buildArgsPatterns(undefined, prefix, undefined);
const regex = new RegExp(patterns[0]!);
const regex = new RegExp(patterns[0].pattern!);
// Mimic JSON stringified args
// echo "foo" -> {"command":"echo \"foo\""}
@@ -131,7 +149,7 @@ describe('policy/utils', () => {
// Testing that we blocked the hole: "echo\foo"
const prefix = 'echo ';
const patterns = buildArgsPatterns(undefined, prefix, undefined);
const regex = new RegExp(patterns[0]!);
const regex = new RegExp(patterns[0].pattern!);
// echo\foo -> {"command":"echo\\foo"}
// In regex matching: "echo " is followed by "\" which is NOT in [\s"] and is not \"
@@ -140,7 +158,7 @@ describe('policy/utils', () => {
// Also validation for "git " matching "git\status"
const gitPatterns = buildArgsPatterns(undefined, 'git ', undefined);
const gitRegex = new RegExp(gitPatterns[0]!);
const gitRegex = new RegExp(gitPatterns[0].pattern!);
// git\status -> {"command":"git\\status"}
const gitAttack = '{"command":"git\\\\status"}';
expect(gitAttack).not.toMatch(gitRegex);
+32 -9
View File
@@ -42,22 +42,25 @@ export function isSafeRegExp(pattern: string): boolean {
return true;
}
export interface ArgsPatternResult {
pattern: string | undefined;
display: string | undefined;
}
/**
* Builds a list of args patterns for policy matching.
*
* This function handles the transformation of command prefixes and regexes into
* the internal argsPattern representation used by the PolicyEngine.
* Normalizes tool arguments (command prefix or raw regex) into strict Regular Expressions
* for the policy engine. Returns both the compiled string pattern and a human-readable display string.
*
* @param argsPattern An optional raw regex string for arguments.
* @param commandPrefix An optional command prefix (or list of prefixes) to allow.
* @param commandRegex An optional command regex string to allow.
* @returns An array of string patterns (or undefined) for the PolicyEngine.
* @returns An array of pattern results for the PolicyEngine.
*/
export function buildArgsPatterns(
argsPattern?: string,
commandPrefix?: string | string[],
commandRegex?: string,
): Array<string | undefined> {
): ArgsPatternResult[] {
if (commandPrefix) {
const prefixes = Array.isArray(commandPrefix)
? commandPrefix
@@ -78,15 +81,35 @@ export function buildArgsPatterns(
// We allow [\s], ["], or the specific sequence [\"] (for escaped quotes
// in JSON). We do NOT allow generic [\\], which would match "git\status"
// -> "gitstatus".
return `${matchSegment}(?:[\\s"]|\\\\")`;
const pattern = `${matchSegment}(?:[\\s"]|\\\\")`;
return { pattern, display: `${prefix}*` };
});
}
if (commandRegex) {
return [`"command":"${commandRegex}`];
return [{ pattern: `"command":"${commandRegex}`, display: commandRegex }];
}
return [argsPattern];
// Raw argsPattern fallback logic for display
let display: string | undefined = undefined;
if (argsPattern) {
if (argsPattern.includes('"file_path"')) {
const pathMatch = argsPattern.match(/"file_path":"(.+?)"/);
if (pathMatch) {
display = `path: ${pathMatch[1]}`;
} else {
display = 'path: ...';
}
} else {
const maxLen = 40;
display =
argsPattern.length > maxLen
? argsPattern.substring(0, maxLen) + '...'
: argsPattern;
}
}
return [{ pattern: argsPattern, display }];
}
/**