mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-17 21:40:39 -07:00
feat(ui): add policies dialog
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatArgsPattern, buildPolicyListItems } from './policyUtils.js';
|
||||
import { PolicyDecision } from '@google/gemini-cli-core';
|
||||
|
||||
describe('formatArgsPattern', () => {
|
||||
it('should return undefined for undefined argsPattern', () => {
|
||||
expect(formatArgsPattern(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse commandPrefix pattern (git diff)', () => {
|
||||
// buildArgsPatterns produces this for commandPrefix: "git diff"
|
||||
const pattern = new RegExp('"command":"git\\ diff(?:[\\s"]|\\\\")');
|
||||
expect(formatArgsPattern(pattern)).toBe('git diff*');
|
||||
});
|
||||
|
||||
it('should parse commandPrefix with special chars (npm run test:ci)', () => {
|
||||
// escapeRegex escapes the colon
|
||||
const pattern = new RegExp(
|
||||
'"command":"npm\\ run\\ test\\:ci(?:[\\s"]|\\\\")',
|
||||
);
|
||||
expect(formatArgsPattern(pattern)).toBe('npm run test:ci*');
|
||||
});
|
||||
|
||||
it('should parse commandPrefix with single word (ls)', () => {
|
||||
const pattern = new RegExp('"command":"ls(?:[\\s"]|\\\\")');
|
||||
expect(formatArgsPattern(pattern)).toBe('ls*');
|
||||
});
|
||||
|
||||
it('should parse commandRegex pattern', () => {
|
||||
const pattern = new RegExp('"command":"npm run .*');
|
||||
expect(formatArgsPattern(pattern)).toBe('npm run .*');
|
||||
});
|
||||
|
||||
it('should parse file_path pattern', () => {
|
||||
const pattern = new RegExp('"file_path":".*\\/plans\\/.*"');
|
||||
expect(formatArgsPattern(pattern)).toBe('path: .*\\/plans\\/.*');
|
||||
});
|
||||
|
||||
it('should return file_path fallback for unrecognized file_path shape', () => {
|
||||
const pattern = new RegExp('"file_path"');
|
||||
expect(formatArgsPattern(pattern)).toBe('path: ...');
|
||||
});
|
||||
|
||||
it('should return raw source for unknown short regex', () => {
|
||||
const pattern = new RegExp('something_unknown');
|
||||
expect(formatArgsPattern(pattern)).toBe('something_unknown');
|
||||
});
|
||||
|
||||
it('should truncate long unknown patterns', () => {
|
||||
const longPattern = new RegExp('a'.repeat(50));
|
||||
const result = formatArgsPattern(longPattern);
|
||||
expect(result).toBe('a'.repeat(40) + '...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPolicyListItems', () => {
|
||||
const toolDisplayNames = new Map([
|
||||
['run_shell_command', 'Shell'],
|
||||
['glob', 'FindFiles'],
|
||||
['read_file', 'ReadFile'],
|
||||
]);
|
||||
|
||||
it('should filter by decision correctly', () => {
|
||||
const rules = [
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 10 },
|
||||
{ decision: PolicyDecision.DENY, toolName: 'read_file', priority: 5 },
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'read_file', priority: 3 },
|
||||
];
|
||||
|
||||
const allowItems = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(allowItems).toHaveLength(2);
|
||||
expect(allowItems[0].toolDisplayName).toBe('FindFiles');
|
||||
expect(allowItems[1].toolDisplayName).toBe('ReadFile');
|
||||
|
||||
const denyItems = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
expect(denyItems).toHaveLength(1);
|
||||
expect(denyItems[0].toolDisplayName).toBe('ReadFile');
|
||||
});
|
||||
|
||||
it('should sort by priority descending', () => {
|
||||
const rules = [
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 1 },
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
toolName: 'run_shell_command',
|
||||
priority: 10,
|
||||
},
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'read_file', priority: 5 },
|
||||
];
|
||||
|
||||
const items = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(items[0].toolDisplayName).toBe('Shell');
|
||||
expect(items[1].toolDisplayName).toBe('ReadFile');
|
||||
expect(items[2].toolDisplayName).toBe('FindFiles');
|
||||
});
|
||||
|
||||
it('should resolve display names from map', () => {
|
||||
const rules = [
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'run_shell_command' },
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'unknown_tool' },
|
||||
{ decision: PolicyDecision.ALLOW },
|
||||
];
|
||||
|
||||
const items = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(items[0].toolDisplayName).toBe('Shell');
|
||||
expect(items[1].toolDisplayName).toBe('unknown_tool');
|
||||
expect(items[2].toolDisplayName).toBe('all tools');
|
||||
});
|
||||
|
||||
it('should include constraint in searchText', () => {
|
||||
const rules = [
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
toolName: 'run_shell_command',
|
||||
argsPattern: new RegExp('"command":"git\\ diff(?:[\\s"]|\\\\")'),
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(items[0].searchText).toContain('Shell');
|
||||
expect(items[0].searchText).toContain('git diff*');
|
||||
});
|
||||
|
||||
it('should include both display name and internal name in searchText', () => {
|
||||
const rules = [
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
toolName: 'run_shell_command',
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(items[0].searchText).toContain('Shell');
|
||||
expect(items[0].searchText).toContain('run_shell_command');
|
||||
});
|
||||
|
||||
it('should format constraint from argsPattern', () => {
|
||||
const rules = [
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
toolName: 'run_shell_command',
|
||||
argsPattern: new RegExp('"command":"git\\ show(?:[\\s"]|\\\\")'),
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(items[0].constraint).toBe('git show*');
|
||||
});
|
||||
|
||||
it('should have undefined constraint when no argsPattern', () => {
|
||||
const rules = [
|
||||
{ decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 5 },
|
||||
];
|
||||
|
||||
const items = buildPolicyListItems(
|
||||
rules,
|
||||
toolDisplayNames,
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(items[0].constraint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { PolicyRule, PolicyDecision } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Represents a single item in the policies dialog list.
|
||||
*/
|
||||
export interface PolicyListItem {
|
||||
/** Unique key for React rendering */
|
||||
key: string;
|
||||
/** The original policy rule */
|
||||
rule: PolicyRule;
|
||||
/** Uppercased decision string */
|
||||
decision: string;
|
||||
/** Resolved display name (e.g. "Shell") or fallback to internal name */
|
||||
toolDisplayName: string;
|
||||
/** Formatted constraint string for parenthetical display, or undefined */
|
||||
constraint: string | undefined;
|
||||
/** Formatted priority string */
|
||||
priority: string;
|
||||
/** rule.source ?? '' */
|
||||
source: string;
|
||||
/** Concatenated searchable fields */
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-escapes a regex string that was escaped by `escapeRegex()` from
|
||||
* packages/core/src/policy/utils.ts.
|
||||
*
|
||||
* escapeRegex escapes: [-[\]{}()*+?.,\\^$|#\s"]
|
||||
* by prefixing each with a backslash.
|
||||
*/
|
||||
function unescapeRegex(escaped: string): string {
|
||||
return escaped.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an argsPattern regex into a human-readable constraint string.
|
||||
*
|
||||
* The policy engine compiles TOML rule fields (commandPrefix, commandRegex,
|
||||
* argsPattern) into RegExp objects via buildArgsPatterns() in
|
||||
* packages/core/src/policy/utils.ts. This function reverses that compilation
|
||||
* back into readable text for display.
|
||||
*
|
||||
* Pattern formats (checked in order):
|
||||
*
|
||||
* 1. commandPrefix — `"command":"<escaped>(?:[\s"]|\\")` → `<prefix>*`
|
||||
* 2. commandRegex — starts with `"command":"` → raw regex after the prefix
|
||||
* 3. file_path — contains `"file_path":"<path>"` → `path: <path>`
|
||||
* 4. Fallback — truncated raw regex source
|
||||
*
|
||||
* Returns undefined if there is no constraint (tool-only or wildcard rules).
|
||||
*/
|
||||
export function formatArgsPattern(
|
||||
argsPattern: RegExp | undefined,
|
||||
): string | undefined {
|
||||
if (!argsPattern) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = argsPattern.source;
|
||||
|
||||
// 1. commandPrefix: "command":"<escaped-prefix>(?:[\s"]|\\")"
|
||||
// The lookahead ensures the prefix is word-bounded in JSON.
|
||||
const prefixMatch = source.match(/^"command":"(.+?)\(\?:\[\\s"\]\|\\\\"?\)$/);
|
||||
if (prefixMatch) {
|
||||
return unescapeRegex(prefixMatch[1]) + '*';
|
||||
}
|
||||
|
||||
// 2. commandRegex: starts with "command":"
|
||||
if (source.startsWith('"command":"')) {
|
||||
const regex = source.slice('"command":"'.length);
|
||||
return regex;
|
||||
}
|
||||
|
||||
// 3. file_path pattern
|
||||
if (source.includes('"file_path"')) {
|
||||
const pathMatch = source.match(/"file_path":"(.+?)"/);
|
||||
if (pathMatch) {
|
||||
return `path: ${pathMatch[1]}`;
|
||||
}
|
||||
return 'path: ...';
|
||||
}
|
||||
|
||||
// 4. Fallback: truncated raw regex
|
||||
const maxLen = 40;
|
||||
if (source.length > maxLen) {
|
||||
return source.substring(0, maxLen) + '...';
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a list of PolicyListItems from rules, filtered by decision and sorted
|
||||
* by priority descending.
|
||||
*/
|
||||
export function buildPolicyListItems(
|
||||
rules: readonly PolicyRule[],
|
||||
toolDisplayNames: Map<string, string>,
|
||||
decision: PolicyDecision,
|
||||
): PolicyListItem[] {
|
||||
return rules
|
||||
.map((rule, index) => ({ rule, index }))
|
||||
.filter(({ rule }) => rule.decision === decision)
|
||||
.sort((a, b) => (b.rule.priority ?? 0) - (a.rule.priority ?? 0))
|
||||
.map(({ rule, index }) => {
|
||||
const toolDisplayName = rule.toolName
|
||||
? (toolDisplayNames.get(rule.toolName) ?? rule.toolName)
|
||||
: 'all tools';
|
||||
const constraint = formatArgsPattern(rule.argsPattern);
|
||||
const priority = String(rule.priority ?? 0);
|
||||
const source = rule.source ?? '';
|
||||
|
||||
const searchText = [toolDisplayName, rule.toolName, constraint, source]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
key: `policy-${index}`,
|
||||
rule,
|
||||
decision: rule.decision.toUpperCase(),
|
||||
toolDisplayName,
|
||||
constraint,
|
||||
priority,
|
||||
source,
|
||||
searchText,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user