mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c618fcb2be | |||
| f518fd6004 | |||
| d5da95eb5c | |||
| cb25b5f7c3 | |||
| cdd0814bdf | |||
| bda7a9a625 | |||
| 44f6a0fafa | |||
| 326edee741 | |||
| bfb13b4fc3 | |||
| 27a56f4f7a | |||
| 90bdba9059 | |||
| e1febb6849 | |||
| 6a4f022a4f | |||
| 4204813895 | |||
| 3de096b444 | |||
| 8faed58289 | |||
| 96cd5b7c5a | |||
| 480ac50204 | |||
| 7ff4402dbd | |||
| 2b96590e94 |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user