diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0c52c9bc4b..c02637be8f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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(), ); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4a17ae8ecc..d6beff1bf6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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; diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 70536070eb..145a466a88 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -18,6 +18,7 @@ import { type Settings } from './settings.js'; export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, + projectPoliciesDir?: string, ): Promise { // 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( diff --git a/packages/cli/src/config/project-policy-cli.test.ts b/packages/cli/src/config/project-policy-cli.test.ts new file mode 100644 index 0000000000..6d7d5d5ac0 --- /dev/null +++ b/packages/cli/src/config/project-policy-cli.test.ts @@ -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( + '@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, + ); + }); +}); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bd0fec1c8e..b090509c36 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -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 }); } diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index efa5083504..3bb324a38e 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -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 { 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): diff --git a/packages/core/src/policy/project-policy.test.ts b/packages/core/src/policy/project-policy.test.ts new file mode 100644 index 0000000000..73018b0821 --- /dev/null +++ b/packages/core/src/policy/project-policy.test.ts @@ -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( + '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>; + if (normalizedPath.includes('user')) + return [ + { name: 'user.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + if (normalizedPath.includes('project')) + return [ + { + name: 'project.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.includes('system')) + return [ + { name: 'admin.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + 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( + '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>; + 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( + '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>; + 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); + }); +}); diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index a627064d41..022bddc048 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -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(