feat(policy): implement project-level policy support

Introduces a new 'Project' tier (Tier 3) for policies, allowing users to define
project-specific rules in `$PROJECT_ROOT/.gemini/policies`.

Key Changes:
- **Core**: Added `PROJECT_POLICY_TIER` (3) and bumped `ADMIN_POLICY_TIER` to 4.
  Updated `getPolicyDirectories`, `getPolicyTier`, and `createPolicyEngineConfig` to handle
  project-level policy directories.
- **Storage**: Added `getProjectPoliciesDir()` to the `Storage` class.
- **CLI**: Updated `loadCliConfig` to securely load project policies.
  Crucially, project policies are **only loaded if the workspace is trusted**.
- **Tests**: Added comprehensive tests for both core policy logic and CLI integration,
  verifying priority hierarchy (Admin > Project > User > Default) and trust checks.

This hierarchy ensures that project-specific rules override user defaults but are still
subject to system-wide admin enforcement.
This commit is contained in:
Abhijit Balaji
2026-02-09 13:42:02 -08:00
parent 261788cf91
commit 322de4309d
8 changed files with 392 additions and 21 deletions
+4
View File
@@ -3198,6 +3198,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -3219,6 +3221,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
+7
View File
@@ -40,6 +40,7 @@ import {
Config,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
Storage,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -692,9 +693,15 @@ export async function loadCliConfig(
policyPaths: argv.policy,
};
let projectPoliciesDir: string | undefined;
if (trustedFolder) {
projectPoliciesDir = new Storage(cwd).getProjectPoliciesDir();
}
const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
projectPoliciesDir,
);
policyEngineConfig.nonInteractive = !interactive;
+7 -1
View File
@@ -18,6 +18,7 @@ import { type Settings } from './settings.js';
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
projectPoliciesDir?: string,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -28,7 +29,12 @@ export async function createPolicyEngineConfig(
policyPaths: settings.policyPaths,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
return createCorePolicyEngineConfig(
policySettings,
approvalMode,
undefined,
projectPoliciesDir,
);
}
export function createPolicyUpdater(
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'node:path';
import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual<typeof ServerConfig>(
'@google/gemini-cli-core',
);
return {
...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
createPolicyEngineConfig: vi.fn().mockResolvedValue({
rules: [],
checkers: [],
}),
getVersion: vi.fn().mockResolvedValue('test-version'),
};
});
describe('Project-Level Policy CLI Integration', () => {
const MOCK_CWD = process.cwd();
beforeEach(() => {
vi.clearAllMocks();
});
it('should have getProjectPoliciesDir on Storage class', () => {
const storage = new ServerConfig.Storage(MOCK_CWD);
expect(storage.getProjectPoliciesDir).toBeDefined();
expect(typeof storage.getProjectPoliciesDir).toBe('function');
});
it('should pass projectPoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
// The wrapper createPolicyEngineConfig in policy.ts calls createCorePolicyEngineConfig
// We check if the core one was called with 4 arguments, the 4th being the project dir
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
undefined, // defaultPoliciesDir
expect.stringContaining(path.join('.gemini', 'policies')),
);
});
it('should NOT pass projectPoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
// The 4th argument (projectPoliciesDir) should be undefined
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
undefined,
undefined,
);
});
});
+4
View File
@@ -136,6 +136,10 @@ export class Storage {
return path.join(tempDir, identifier);
}
getProjectPoliciesDir(): string {
return path.join(this.getGeminiDir(), 'policies');
}
ensureProjectTempDirExists(): void {
fs.mkdirSync(this.getProjectTempDir(), { recursive: true });
}
+32 -16
View File
@@ -39,46 +39,54 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
// Policy tier constants for priority calculation
export const DEFAULT_POLICY_TIER = 1;
export const USER_POLICY_TIER = 2;
export const ADMIN_POLICY_TIER = 3;
export const PROJECT_POLICY_TIER = 3;
export const ADMIN_POLICY_TIER = 4;
/**
* Gets the list of directories to search for policy files, in order of decreasing priority
* (Admin -> User -> Default).
* Gets the list of directories to search for policy files, in order of increasing priority
* (Default -> User -> Project -> Admin).
*
* @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.
* @param projectPoliciesDir Optional path to a directory containing project policies.
*/
export function getPolicyDirectories(
defaultPoliciesDir?: string,
policyPaths?: string[],
projectPoliciesDir?: string,
): string[] {
const dirs: string[] = [];
const dirs = [];
// Default tier (lowest priority)
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
// Admin tier (highest priority)
dirs.push(Storage.getSystemPoliciesDir());
// User tier (middle priority)
// User tier (second higheset priority)
if (policyPaths && policyPaths.length > 0) {
dirs.push(...policyPaths);
} else {
dirs.push(Storage.getUserPoliciesDir());
}
// Project Tier (third highest)
if (projectPoliciesDir) {
dirs.push(projectPoliciesDir);
}
// Admin tier (highest priority)
dirs.push(Storage.getSystemPoliciesDir());
// Default tier (lowest priority)
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
// Reverse so highest priority (Admin) is first
return dirs.reverse();
return dirs;
}
/**
* Determines the policy tier (1=default, 2=user, 3=admin) for a given directory.
* Determines the policy tier (1=default, 2=user, 3=project, 4=admin) for a given directory.
* This is used by the TOML loader to assign priority bands.
*/
export function getPolicyTier(
dir: string,
defaultPoliciesDir?: string,
projectPoliciesDir?: string,
): number {
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
@@ -99,6 +107,12 @@ export function getPolicyTier(
if (normalizedDir === normalizedUser) {
return USER_POLICY_TIER;
}
if (
projectPoliciesDir &&
normalizedDir === path.resolve(projectPoliciesDir)
) {
return PROJECT_POLICY_TIER;
}
if (normalizedDir === normalizedAdmin) {
return ADMIN_POLICY_TIER;
}
@@ -153,12 +167,13 @@ export async function createPolicyEngineConfig(
settings: PolicySettings,
approvalMode: ApprovalMode,
defaultPoliciesDir?: string,
projectPoliciesDir?: string,
): Promise<PolicyEngineConfig> {
const policyDirs = getPolicyDirectories(
defaultPoliciesDir,
settings.policyPaths,
projectPoliciesDir,
);
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
const normalizedAdminPoliciesDir = path.resolve(
@@ -171,7 +186,7 @@ export async function createPolicyEngineConfig(
checkers: tomlCheckers,
errors,
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
const tier = getPolicyTier(p, defaultPoliciesDir);
const tier = getPolicyTier(p, defaultPoliciesDir, projectPoliciesDir);
// If it's a user-provided path that isn't already categorized as ADMIN,
// treat it as USER tier.
@@ -208,9 +223,10 @@ export async function createPolicyEngineConfig(
// Priority bands (tiers):
// - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
// - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
// - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
// - Project policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
// - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
//
// This ensures Admin > User > Default hierarchy is always preserved,
// This ensures Admin > Project > User > Default hierarchy is always preserved,
// while allowing user-specified priorities to work within each tier.
//
// Settings-based and dynamic rules (all in user tier 2.x):
@@ -0,0 +1,242 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import nodePath from 'node:path';
import { ApprovalMode } from './types.js';
import { isDirectorySecure } from '../utils/security.js';
// Mock dependencies
vi.mock('../utils/security.js', () => ({
isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }),
}));
describe('Project-Level Policies', () => {
beforeEach(async () => {
vi.resetModules();
const { Storage } = await import('../config/storage.js');
vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(
'/mock/user/policies',
);
vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
'/mock/system/policies',
);
// Ensure security check always returns secure
vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true });
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vi.doUnmock('node:fs/promises');
});
it('should load project policies with correct priority (Tier 3)', async () => {
const projectPoliciesDir = '/mock/project/policies';
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
// Mock readdir to return a policy file for each tier
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.includes('default'))
return [
{
name: 'default.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.includes('user'))
return [
{ name: 'user.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.includes('project'))
return [
{
name: 'project.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.includes('system'))
return [
{ name: 'admin.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
// Mock readFile to return content with distinct priorities/decisions
const mockReadFile = vi.fn(async (path: string) => {
if (path.includes('default.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
priority = 10
`; // Tier 1 -> 1.010
}
if (path.includes('user.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "deny"
priority = 10
`; // Tier 2 -> 2.010
}
if (path.includes('project.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
priority = 10
`; // Tier 3 -> 3.010
}
if (path.includes('admin.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "deny"
priority = 10
`; // Tier 4 -> 4.010
}
return '';
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir, readFile: mockReadFile },
readdir: mockReaddir,
readFile: mockReadFile,
}));
const { createPolicyEngineConfig } = await import('./config.js');
// Test 1: Project vs User (Project should win)
const config = await createPolicyEngineConfig(
{},
ApprovalMode.DEFAULT,
defaultPoliciesDir,
projectPoliciesDir,
);
const rules = config.rules?.filter((r) => r.toolName === 'test_tool');
expect(rules).toBeDefined();
// Check for all 4 rules
const defaultRule = rules?.find((r) => r.priority === 1.01);
const userRule = rules?.find((r) => r.priority === 2.01);
const projectRule = rules?.find((r) => r.priority === 3.01);
const adminRule = rules?.find((r) => r.priority === 4.01);
expect(defaultRule).toBeDefined();
expect(userRule).toBeDefined();
expect(projectRule).toBeDefined();
expect(adminRule).toBeDefined();
// Verify Hierarchy: Admin > Project > User > Default
expect(adminRule!.priority).toBeGreaterThan(projectRule!.priority!);
expect(projectRule!.priority).toBeGreaterThan(userRule!.priority!);
expect(userRule!.priority).toBeGreaterThan(defaultRule!.priority!);
});
it('should ignore project policies if projectPoliciesDir is undefined', async () => {
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS (simplified)
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(async (path: string) => {
if (path.includes('default'))
return [
{
name: 'default.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
const mockReadFile = vi.fn(
async () => `[[rule]]
toolName="t"
decision="allow"
priority=10`,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir, readFile: mockReadFile },
readdir: mockReaddir,
readFile: mockReadFile,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const config = await createPolicyEngineConfig(
{},
ApprovalMode.DEFAULT,
defaultPoliciesDir,
undefined, // No project dir
);
// Should only have default tier rule (1.01)
const rules = config.rules;
expect(rules).toHaveLength(1);
expect(rules![0].priority).toBe(1.01);
});
it('should load project policies and correctly transform to Tier 3', async () => {
const projectPoliciesDir = '/mock/project/policies';
// Mock FS
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(async (path: string) => {
if (path.includes('project'))
return [
{
name: 'project.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
const mockReadFile = vi.fn(
async () => `[[rule]]
toolName="p_tool"
decision="allow"
priority=500`,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir, readFile: mockReadFile },
readdir: mockReaddir,
readFile: mockReadFile,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const config = await createPolicyEngineConfig(
{},
ApprovalMode.DEFAULT,
undefined,
projectPoliciesDir,
);
const rule = config.rules?.find((r) => r.toolName === 'p_tool');
expect(rule).toBeDefined();
// Project Tier (3) + 500/1000 = 3.5
expect(rule?.priority).toBe(3.5);
});
});
+5 -4
View File
@@ -105,7 +105,7 @@ export type PolicyFileErrorType =
export interface PolicyFileError {
filePath: string;
fileName: string;
tier: 'default' | 'user' | 'admin';
tier: 'default' | 'user' | 'project' | 'admin';
ruleIndex?: number;
errorType: PolicyFileErrorType;
message: string;
@@ -125,10 +125,11 @@ export interface PolicyLoadResult {
/**
* Converts a tier number to a human-readable tier name.
*/
function getTierName(tier: number): 'default' | 'user' | 'admin' {
function getTierName(tier: number): 'default' | 'user' | 'project' | 'admin' {
if (tier === 1) return 'default';
if (tier === 2) return 'user';
if (tier === 3) return 'admin';
if (tier === 3) return 'project';
if (tier === 4) return 'admin';
return 'default';
}
@@ -211,7 +212,7 @@ function transformPriority(priority: number, tier: number): number {
* 4. Collects detailed error information for any failures
*
* @param policyPaths Array of paths (directories or files) to scan for policy files
* @param getPolicyTier Function to determine tier (1-3) for a path
* @param getPolicyTier Function to determine tier (1-4) for a path
* @returns Object containing successfully parsed rules and any errors encountered
*/
export async function loadPoliciesFromToml(