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:
Allen Hutchison
2026-02-12 16:25:23 -08:00
committed by GitHub
parent 5b4884692b
commit 696198be87
13 changed files with 326 additions and 49 deletions
+144 -6
View File
@@ -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 = {};
+45 -15
View File
@@ -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
+23 -8
View File
@@ -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);
});
});
+29 -19
View File
@@ -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
+1
View File
@@ -272,6 +272,7 @@ export interface PolicySettings {
allowed?: string[];
};
mcpServers?: Record<string, { trust?: boolean }>;
policyPaths?: string[];
}
export interface CheckResult {