mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
feat(policy): add --policy flag for user defined policies (#18500)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -463,6 +463,21 @@ describe('createPolicyEngineConfig', () => {
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const mockStat = vi.fn(async (p) => {
|
||||
if (typeof p === 'string' && p.includes('/tmp/mock/default/policies')) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
if (typeof p === 'string' && p.includes('default.toml')) {
|
||||
return {
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(p);
|
||||
});
|
||||
const mockReadFile = vi.fn(async (p, _o) => {
|
||||
if (typeof p === 'string' && p.includes('default.toml')) {
|
||||
return '[[rule]]\ntoolName = "glob"\ndecision = "allow"\npriority = 50\n';
|
||||
@@ -471,9 +486,15 @@ describe('createPolicyEngineConfig', () => {
|
||||
});
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
...actualFs,
|
||||
default: { ...actualFs, readdir: mockReaddir, readFile: mockReadFile },
|
||||
default: {
|
||||
...actualFs,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
},
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
}));
|
||||
vi.resetModules();
|
||||
const { createPolicyEngineConfig: createConfig } = await import(
|
||||
@@ -663,11 +684,37 @@ priority = 150
|
||||
},
|
||||
);
|
||||
|
||||
const mockStat = vi.fn(
|
||||
async (
|
||||
path: Parameters<typeof actualFs.stat>[0],
|
||||
options?: Parameters<typeof actualFs.stat>[1],
|
||||
) => {
|
||||
if (
|
||||
typeof path === 'string' &&
|
||||
nodePath
|
||||
.normalize(path)
|
||||
.includes(nodePath.normalize('.gemini/policies'))
|
||||
) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path, options);
|
||||
},
|
||||
);
|
||||
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
...actualFs,
|
||||
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
|
||||
default: {
|
||||
...actualFs,
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
},
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
@@ -766,11 +813,37 @@ required_context = ["environment"]
|
||||
},
|
||||
);
|
||||
|
||||
const mockStat = vi.fn(
|
||||
async (
|
||||
path: Parameters<typeof actualFs.stat>[0],
|
||||
options?: Parameters<typeof actualFs.stat>[1],
|
||||
) => {
|
||||
if (
|
||||
typeof path === 'string' &&
|
||||
nodePath
|
||||
.normalize(path)
|
||||
.includes(nodePath.normalize('.gemini/policies'))
|
||||
) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path, options);
|
||||
},
|
||||
);
|
||||
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
...actualFs,
|
||||
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
|
||||
default: {
|
||||
...actualFs,
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
},
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
@@ -862,11 +935,37 @@ name = "invalid-name"
|
||||
},
|
||||
);
|
||||
|
||||
const mockStat = vi.fn(
|
||||
async (
|
||||
path: Parameters<typeof actualFs.stat>[0],
|
||||
options?: Parameters<typeof actualFs.stat>[1],
|
||||
) => {
|
||||
if (
|
||||
typeof path === 'string' &&
|
||||
nodePath
|
||||
.normalize(path)
|
||||
.includes(nodePath.normalize('.gemini/policies'))
|
||||
) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path, options);
|
||||
},
|
||||
);
|
||||
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
...actualFs,
|
||||
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
|
||||
default: {
|
||||
...actualFs,
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
},
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
@@ -964,7 +1063,7 @@ name = "invalid-name"
|
||||
options?: Parameters<typeof actualFs.readdir>[1],
|
||||
) => {
|
||||
const normalizedPath = nodePath.normalize(path.toString());
|
||||
if (normalizedPath.includes(nodePath.normalize('.gemini/policies'))) {
|
||||
if (normalizedPath.includes('gemini-cli-test/user/policies')) {
|
||||
return [
|
||||
{
|
||||
name: 'user-plan.toml',
|
||||
@@ -980,6 +1079,22 @@ name = "invalid-name"
|
||||
},
|
||||
);
|
||||
|
||||
const mockStat = vi.fn(
|
||||
async (
|
||||
path: Parameters<typeof actualFs.stat>[0],
|
||||
options?: Parameters<typeof actualFs.stat>[1],
|
||||
) => {
|
||||
const normalizedPath = nodePath.normalize(path.toString());
|
||||
if (normalizedPath.includes('gemini-cli-test/user/policies')) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path, options);
|
||||
},
|
||||
);
|
||||
|
||||
const mockReadFile = vi.fn(
|
||||
async (
|
||||
path: Parameters<typeof actualFs.readFile>[0],
|
||||
@@ -1008,12 +1123,35 @@ modes = ["plan"]
|
||||
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
...actualFs,
|
||||
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
|
||||
default: {
|
||||
...actualFs,
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
},
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
// Robustly mock Storage using doMock to ensure it persists through imports in config.js
|
||||
vi.doMock('../config/storage.js', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../config/storage.js')
|
||||
>('../config/storage.js');
|
||||
class MockStorage extends actual.Storage {
|
||||
static override getUserPoliciesDir() {
|
||||
return '/tmp/gemini-cli-test/user/policies';
|
||||
}
|
||||
static override getSystemPoliciesDir() {
|
||||
return '/tmp/gemini-cli-test/system/policies';
|
||||
}
|
||||
}
|
||||
return { ...actual, Storage: MockStorage };
|
||||
});
|
||||
|
||||
const { createPolicyEngineConfig } = await import('./config.js');
|
||||
|
||||
const settings: PolicySettings = {};
|
||||
|
||||
@@ -42,26 +42,33 @@ export const USER_POLICY_TIER = 2;
|
||||
export const ADMIN_POLICY_TIER = 3;
|
||||
|
||||
/**
|
||||
* Gets the list of directories to search for policy files, in order of increasing priority
|
||||
* (Default -> User -> Admin).
|
||||
* Gets the list of directories to search for policy files, in order of decreasing priority
|
||||
* (Admin -> User -> Default).
|
||||
*
|
||||
* @param defaultPoliciesDir Optional path to a directory containing default policies.
|
||||
* @param policyPaths Optional user-provided policy paths (from --policy flag).
|
||||
* When provided, these replace the default user policies directory.
|
||||
*/
|
||||
export function getPolicyDirectories(defaultPoliciesDir?: string): string[] {
|
||||
const dirs = [];
|
||||
export function getPolicyDirectories(
|
||||
defaultPoliciesDir?: string,
|
||||
policyPaths?: string[],
|
||||
): string[] {
|
||||
const dirs: string[] = [];
|
||||
|
||||
if (defaultPoliciesDir) {
|
||||
dirs.push(defaultPoliciesDir);
|
||||
// Default tier (lowest priority)
|
||||
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
|
||||
|
||||
// User tier (middle priority)
|
||||
if (policyPaths && policyPaths.length > 0) {
|
||||
dirs.push(...policyPaths);
|
||||
} else {
|
||||
dirs.push(DEFAULT_CORE_POLICIES_DIR);
|
||||
dirs.push(Storage.getUserPoliciesDir());
|
||||
}
|
||||
|
||||
dirs.push(Storage.getUserPoliciesDir());
|
||||
// Admin tier (highest priority)
|
||||
dirs.push(Storage.getSystemPoliciesDir());
|
||||
|
||||
// Reverse so highest priority (Admin) is first for loading order if needed,
|
||||
// though loadPoliciesFromToml might want them in a specific order.
|
||||
// CLI implementation reversed them: [DEFAULT, USER, ADMIN].reverse() -> [ADMIN, USER, DEFAULT]
|
||||
// Reverse so highest priority (Admin) is first
|
||||
return dirs.reverse();
|
||||
}
|
||||
|
||||
@@ -147,17 +154,40 @@ export async function createPolicyEngineConfig(
|
||||
approvalMode: ApprovalMode,
|
||||
defaultPoliciesDir?: string,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
const policyDirs = getPolicyDirectories(defaultPoliciesDir);
|
||||
const policyDirs = getPolicyDirectories(
|
||||
defaultPoliciesDir,
|
||||
settings.policyPaths,
|
||||
);
|
||||
|
||||
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
|
||||
|
||||
const normalizedAdminPoliciesDir = path.resolve(
|
||||
Storage.getSystemPoliciesDir(),
|
||||
);
|
||||
|
||||
// Load policies from TOML files
|
||||
const {
|
||||
rules: tomlRules,
|
||||
checkers: tomlCheckers,
|
||||
errors,
|
||||
} = await loadPoliciesFromToml(securePolicyDirs, (dir) =>
|
||||
getPolicyTier(dir, defaultPoliciesDir),
|
||||
);
|
||||
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
|
||||
const tier = getPolicyTier(p, defaultPoliciesDir);
|
||||
|
||||
// If it's a user-provided path that isn't already categorized as ADMIN,
|
||||
// treat it as USER tier.
|
||||
if (
|
||||
settings.policyPaths?.some(
|
||||
(userPath) => path.resolve(userPath) === path.resolve(p),
|
||||
)
|
||||
) {
|
||||
const normalizedPath = path.resolve(p);
|
||||
if (normalizedPath !== normalizedAdminPoliciesDir) {
|
||||
return USER_POLICY_TIER;
|
||||
}
|
||||
}
|
||||
|
||||
return tier;
|
||||
});
|
||||
|
||||
// Emit any errors encountered during TOML loading to the UI
|
||||
// coreEvents has a buffer that will display these once the UI is ready
|
||||
|
||||
@@ -495,18 +495,33 @@ priority = 100
|
||||
expect(error.message).toBe('Invalid regex pattern');
|
||||
});
|
||||
|
||||
it('should return a file_read error if readdir fails', async () => {
|
||||
// Create a file and pass it as a directory to trigger ENOTDIR
|
||||
const filePath = path.join(tempDir, 'not-a-dir');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
it('should load an individual policy file', async () => {
|
||||
const filePath = path.join(tempDir, 'single-rule.toml');
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
'[[rule]]\ntoolName = "test-tool"\ndecision = "allow"\npriority = 500\n',
|
||||
);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml([filePath], getPolicyTier);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
const error = result.errors[0];
|
||||
expect(error.errorType).toBe('file_read');
|
||||
expect(error.message).toContain('Failed to read policy directory');
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('test-tool');
|
||||
expect(result.rules[0].decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should return a file_read error if stat fails with something other than ENOENT', async () => {
|
||||
// We can't easily trigger a stat error other than ENOENT without mocks,
|
||||
// but we can test that it handles it.
|
||||
// For this test, we'll just check that it handles a non-existent file gracefully (no error)
|
||||
const filePath = path.join(tempDir, 'non-existent.toml');
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml([filePath], getPolicyTier);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.rules).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -202,57 +202,67 @@ function transformPriority(priority: number, tier: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and parses policies from TOML files in the specified directories.
|
||||
* Loads and parses policies from TOML files in the specified paths (directories or individual files).
|
||||
*
|
||||
* This function:
|
||||
* 1. Scans directories for .toml files
|
||||
* 1. Scans paths for .toml files (if directory) or processes individual files
|
||||
* 2. Parses and validates each file
|
||||
* 3. Transforms rules (commandPrefix, arrays, mcpName, priorities)
|
||||
* 4. Collects detailed error information for any failures
|
||||
*
|
||||
* @param policyDirs Array of directory paths to scan for policy files
|
||||
* @param getPolicyTier Function to determine tier (1-3) for a directory
|
||||
* @param policyPaths Array of paths (directories or files) to scan for policy files
|
||||
* @param getPolicyTier Function to determine tier (1-3) for a path
|
||||
* @returns Object containing successfully parsed rules and any errors encountered
|
||||
*/
|
||||
export async function loadPoliciesFromToml(
|
||||
policyDirs: string[],
|
||||
getPolicyTier: (dir: string) => number,
|
||||
policyPaths: string[],
|
||||
getPolicyTier: (path: string) => number,
|
||||
): Promise<PolicyLoadResult> {
|
||||
const rules: PolicyRule[] = [];
|
||||
const checkers: SafetyCheckerRule[] = [];
|
||||
const errors: PolicyFileError[] = [];
|
||||
|
||||
for (const dir of policyDirs) {
|
||||
const tier = getPolicyTier(dir);
|
||||
for (const p of policyPaths) {
|
||||
const tier = getPolicyTier(p);
|
||||
const tierName = getTierName(tier);
|
||||
|
||||
// Scan directory for all .toml files
|
||||
let filesToLoad: string[];
|
||||
let filesToLoad: string[] = [];
|
||||
let baseDir = '';
|
||||
|
||||
try {
|
||||
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
||||
filesToLoad = dirEntries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
|
||||
.map((entry) => entry.name);
|
||||
const stats = await fs.stat(p);
|
||||
if (stats.isDirectory()) {
|
||||
baseDir = p;
|
||||
const dirEntries = await fs.readdir(p, { withFileTypes: true });
|
||||
filesToLoad = dirEntries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
|
||||
.map((entry) => entry.name);
|
||||
} else if (stats.isFile() && p.endsWith('.toml')) {
|
||||
baseDir = path.dirname(p);
|
||||
filesToLoad = [path.basename(p)];
|
||||
}
|
||||
// Other file types or non-.toml files are silently ignored
|
||||
// for consistency with directory scanning behavior.
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const error = e as NodeJS.ErrnoException;
|
||||
if (error.code === 'ENOENT') {
|
||||
// Directory doesn't exist, skip it (not an error)
|
||||
// Path doesn't exist, skip it (not an error)
|
||||
continue;
|
||||
}
|
||||
errors.push({
|
||||
filePath: dir,
|
||||
fileName: path.basename(dir),
|
||||
filePath: p,
|
||||
fileName: path.basename(p),
|
||||
tier: tierName,
|
||||
errorType: 'file_read',
|
||||
message: `Failed to read policy directory`,
|
||||
message: `Failed to read policy path`,
|
||||
details: error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of filesToLoad) {
|
||||
const filePath = path.join(dir, file);
|
||||
const filePath = path.join(baseDir, file);
|
||||
|
||||
try {
|
||||
// Read file
|
||||
|
||||
@@ -272,6 +272,7 @@ export interface PolicySettings {
|
||||
allowed?: string[];
|
||||
};
|
||||
mcpServers?: Record<string, { trust?: boolean }>;
|
||||
policyPaths?: string[];
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
|
||||
Reference in New Issue
Block a user