From be43c86feca96bcb0cfbfc35027e0b3dcd9bb35f Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Mon, 9 Feb 2026 13:42:02 -0800 Subject: [PATCH 01/14] 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. --- packages/cli/src/config/config.test.ts | 4 + packages/cli/src/config/config.ts | 7 + packages/cli/src/config/policy.ts | 8 +- .../cli/src/config/project-policy-cli.test.ts | 91 +++++++ packages/core/src/config/storage.ts | 4 + packages/core/src/policy/config.ts | 48 ++-- .../core/src/policy/project-policy.test.ts | 242 ++++++++++++++++++ packages/core/src/policy/toml-loader.ts | 9 +- 8 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/config/project-policy-cli.test.ts create mode 100644 packages/core/src/policy/project-policy.test.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 809b31cd827..c679ae2db1a 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3217,6 +3217,8 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -3238,6 +3240,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 6b7f3460af2..2d3dcbe8815 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 70536070ebf..145a466a88f 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 00000000000..6d7d5d5ac0e --- /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 bce91f7991d..1eac8fc4c2a 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -146,6 +146,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 efa50835047..3bb324a38ec 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 00000000000..73018b0821d --- /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 a627064d417..022bddc0481 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( From ccae4a1d6cc3e182da3d20fed7569f864a2a8b81 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Mon, 9 Feb 2026 13:53:54 -0800 Subject: [PATCH 02/14] docs(policy): document project-level policy support Adds the 'Project' tier (Base 3) to the policy engine documentation. Updates the priority hierarchy, location table, and formula examples to reflect the new Project -> User precedence. --- docs/reference/policy-engine.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 23e672e4b92..54da64fcf15 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -96,7 +96,8 @@ has a designated number that forms the base of the final priority calculation. | :------ | :--- | :------------------------------------------------------------------------- | | Default | 1 | Built-in policies that ship with the Gemini CLI. | | User | 2 | Custom policies defined by the user. | -| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Project | 3 | Policies defined in the current project's configuration directory. | +| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -105,7 +106,8 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User and Default policies. +- Admin policies always override Project, User, and Default policies. +- Project policies override User and Default policies. - User policies always override Default policies. - You can still order rules within a single tier with fine-grained control. @@ -113,7 +115,8 @@ For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. - A `priority: 100` rule in a User policy file becomes `2.100`. -- A `priority: 20` rule in an Admin policy file becomes `3.020`. +- A `priority: 10` rule in a Project policy file becomes `3.010`. +- A `priority: 20` rule in an Admin policy file becomes `4.020`. ### Approval modes @@ -156,10 +159,11 @@ User, and (if configured) Admin directories. ### Policy locations -| Tier | Type | Location | -| :-------- | :----- | :-------------------------- | -| **User** | Custom | `~/.gemini/policies/*.toml` | -| **Admin** | System | _See below (OS specific)_ | +| Tier | Type | Location | +| :---------- | :----- | :-------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Project** | Custom | `$PROJECT_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) From d84a33f4722fe5815da76a2bc10c3e11e5cbd245 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Mon, 9 Feb 2026 16:17:10 -0800 Subject: [PATCH 03/14] feat(policy): change priority hierarchy to Admin > User > Project > Default Updates the policy engine to prioritize User policies over Project-specific policies. This change is a security measure to ensure that users maintain control over their environment and are not inadvertently compromised by policies defined in a cloned repository. Key Changes: - Swapped Tier 2 (now Project) and Tier 3 (now User). - Updated documentation to reflect the new hierarchy. - Updated all built-in policy TOML files with correct tier information. - Adjusted all tests and integration test expectations to match new priority values. --- docs/reference/policy-engine.md | 14 ++--- .../config/policy-engine.integration.test.ts | 24 ++++---- packages/core/src/policy/config.test.ts | 52 ++++++++-------- packages/core/src/policy/config.ts | 60 +++++++++---------- packages/core/src/policy/persistence.test.ts | 2 +- packages/core/src/policy/policies/plan.toml | 21 +++---- .../core/src/policy/policies/read-only.toml | 21 +++---- packages/core/src/policy/policies/write.toml | 21 +++---- packages/core/src/policy/policies/yolo.toml | 21 +++---- .../core/src/policy/project-policy.test.ts | 26 ++++---- packages/core/src/policy/toml-loader.test.ts | 20 ++++--- packages/core/src/policy/toml-loader.ts | 4 +- 12 files changed, 147 insertions(+), 139 deletions(-) diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 54da64fcf15..352c34be99f 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -95,8 +95,8 @@ has a designated number that forms the base of the final priority calculation. | Tier | Base | Description | | :------ | :--- | :------------------------------------------------------------------------- | | Default | 1 | Built-in policies that ship with the Gemini CLI. | -| User | 2 | Custom policies defined by the user. | -| Project | 3 | Policies defined in the current project's configuration directory. | +| Project | 2 | Policies defined in the current project's configuration directory. | +| User | 3 | Custom policies defined by the user. | | Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The @@ -106,16 +106,16 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override Project, User, and Default policies. -- Project policies override User and Default policies. -- User policies always override Default policies. +- Admin policies always override User, Project, and Default policies. +- User policies override Project and Default policies. +- Project policies override Default policies. - You can still order rules within a single tier with fine-grained control. For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. -- A `priority: 100` rule in a User policy file becomes `2.100`. -- A `priority: 10` rule in a Project policy file becomes `3.010`. +- A `priority: 10` rule in a Project policy file becomes `2.010`. +- A `priority: 100` rule in a User policy file becomes `3.100`. - A `priority: 20` rule in an Admin policy file becomes `4.020`. ### Approval modes diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 2c7ce599daf..dbc7f6a415e 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 2.1) provides general allow for server - // MCP server allowed (priority 2.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server expect( (await engine.check({ name: 'my-server__safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 2.4) wins over server allow + // But specific tool exclude (priority 3.4) wins over server allow expect( (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) .decision, @@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(2.3); // Command line allow + expect(specificToolRule?.priority).toBe(3.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server + expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) @@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier) + expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) + expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 32a52871139..a9fae7a1fa0 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow }); it('should deny tools in tools.exclude', async () => { @@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should allow tools from allowed MCP servers', async () => { @@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.1); // MCP allowed server + expect(rule?.priority).toBe(3.1); // MCP allowed server }); it('should deny tools from excluded MCP servers', async () => { @@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.9); // MCP excluded server + expect(rule?.priority).toBe(3.9); // MCP excluded server }); it('should allow tools from trusted MCP servers', async () => { @@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(2.1); // MCP allowed server + expect(allowedRule?.priority).toBe(3.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(2.9); // MCP excluded server + expect(excludedRule?.priority).toBe(3.9); // MCP excluded server }); it('should allow all tools in YOLO mode', async () => { @@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server + expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow - // Server deny (2.9) has higher priority than tool allow (2.3), + // Server deny (3.9) has higher priority than tool allow (3.3), // so server deny wins (this is expected behavior - server-level blocks are security critical) }); @@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); - // Command line exclude (2.4) has higher priority than MCP server trust (2.2) + // Command line exclude (3.4) has higher priority than MCP server trust (3.2) // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); @@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => { it('should handle complex priority scenarios correctly', async () => { const settings: PolicySettings = { tools: { - allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3 - exclude: ['my-server__tool2', 'glob'], // Priority 2.4 + allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3 + exclude: ['my-server__tool2', 'glob'], // Priority 3.4 }, mcp: { - allowed: ['allowed-server'], // Priority 2.1 - excluded: ['excluded-server'], // Priority 2.9 + allowed: ['allowed-server'], // Priority 3.1 + excluded: ['excluded-server'], // Priority 3.9 }, mcpServers: { 'trusted-server': { - trust: true, // Priority 90 -> 2.2 + trust: true, // Priority 90 -> 3.2 }, }, }; @@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => { expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); // Deny from settings (user tier) - expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude // Allow from default TOML: 1 + 50/1000 = 1.05 expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); @@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes (user tier: 2.4 and 2.9) + // Check that the highest priority items are the excludes (user tier: 3.4 and 3.9) const highestPriorityExcludes = priorities?.filter( (p) => - Math.abs(p.priority! - 2.4) < 0.01 || - Math.abs(p.priority! - 2.9) < 0.01, + Math.abs(p.priority! - 3.4) < 0.01 || + Math.abs(p.priority! - 3.9) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), @@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should support argsPattern in policy rules', async () => { @@ -733,8 +733,8 @@ priority = 150 r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - // Priority 150 in user tier → 2.150 - expect(rule?.priority).toBeCloseTo(2.15, 5); + // Priority 150 in user tier → 3.150 + expect(rule?.priority).toBeCloseTo(3.15, 5); expect(rule?.argsPattern).toBeInstanceOf(RegExp); expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); @@ -1046,7 +1046,7 @@ name = "invalid-name" r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow vi.doUnmock('node:fs/promises'); }); @@ -1188,7 +1188,7 @@ modes = ["plan"] r.modes?.includes(ApprovalMode.PLAN), ); expect(subagentRule).toBeDefined(); - expect(subagentRule?.priority).toBeCloseTo(2.1, 5); + expect(subagentRule?.priority).toBeCloseTo(3.1, 5); vi.doUnmock('node:fs/promises'); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 3bb324a38ec..6a0b6c4145a 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -38,8 +38,8 @@ 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 PROJECT_POLICY_TIER = 3; +export const PROJECT_POLICY_TIER = 2; +export const USER_POLICY_TIER = 3; export const ADMIN_POLICY_TIER = 4; /** @@ -222,20 +222,20 @@ 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) - // - Project policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - User 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 > Project > User > Default hierarchy is always preserved, + // This ensures Admin > User > Project > 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): - // 2.95: Tools that the user has selected as "Always Allow" in the interactive UI - // 2.9: MCP servers excluded list (security: persistent server blocks) - // 2.4: Command line flag --exclude-tools (explicit temporary blocks) - // 2.3: Command line flag --allowed-tools (explicit temporary allows) - // 2.2: MCP servers with trust=true (persistent trusted servers) - // 2.1: MCP servers allowed list (persistent general server allows) + // Settings-based and dynamic rules (all in user tier 3.x): + // 3.95: Tools that the user has selected as "Always Allow" in the interactive UI + // 3.9: MCP servers excluded list (security: persistent server blocks) + // 3.4: Command line flag --exclude-tools (explicit temporary blocks) + // 3.3: Command line flag --allowed-tools (explicit temporary allows) + // 3.2: MCP servers with trust=true (persistent trusted servers) + // 3.1: MCP servers allowed list (persistent general server allows) // // TOML policy priorities (before transformation): // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -246,33 +246,33 @@ export async function createPolicyEngineConfig( // 999: YOLO mode allow-all (becomes 1.999 in default tier) // MCP servers that are explicitly excluded in settings.mcp.excluded - // Priority: 2.9 (highest in user tier for security - persistent server blocks) + // Priority: 3.9 (highest in user tier for security - persistent server blocks) if (settings.mcp?.excluded) { for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.DENY, - priority: 2.9, + priority: 3.9, source: 'Settings (MCP Excluded)', }); } } // Tools that are explicitly excluded in the settings. - // Priority: 2.4 (user tier - explicit temporary blocks) + // Priority: 3.4 (user tier - explicit temporary blocks) if (settings.tools?.exclude) { for (const tool of settings.tools.exclude) { rules.push({ toolName: tool, decision: PolicyDecision.DENY, - priority: 2.4, + priority: 3.4, source: 'Settings (Tools Excluded)', }); } } // Tools that are explicitly allowed in the settings. - // Priority: 2.3 (user tier - explicit temporary allows) + // Priority: 3.3 (user tier - explicit temporary allows) if (settings.tools?.allowed) { for (const tool of settings.tools.allowed) { // Check for legacy format: toolName(args) @@ -292,7 +292,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, argsPattern: new RegExp(pattern), source: 'Settings (Tools Allowed)', }); @@ -304,7 +304,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, source: 'Settings (Tools Allowed)', }); } @@ -316,7 +316,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, source: 'Settings (Tools Allowed)', }); } @@ -324,7 +324,7 @@ export async function createPolicyEngineConfig( } // MCP servers that are trusted in the settings. - // Priority: 2.2 (user tier - persistent trusted servers) + // Priority: 3.2 (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -335,7 +335,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.2, + priority: 3.2, source: 'Settings (MCP Trusted)', }); } @@ -343,13 +343,13 @@ export async function createPolicyEngineConfig( } // MCP servers that are explicitly allowed in settings.mcp.allowed - // Priority: 2.1 (user tier - persistent general server allows) + // Priority: 3.1 (user tier - persistent general server allows) if (settings.mcp?.allowed) { for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.1, + priority: 3.1, source: 'Settings (MCP Allowed)', }); } @@ -396,10 +396,10 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 + // User tier (3) + high priority (950/1000) = 3.95 // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + // but still lose to admin policies (4.xxx) and settings excludes (300) + priority: 3.95, argsPattern: new RegExp(pattern), source: 'Dynamic (Confirmed)', }); @@ -421,10 +421,10 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 + // User tier (3) + high priority (950/1000) = 3.95 // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + // but still lose to admin policies (4.xxx) and settings excludes (300) + priority: 3.95, argsPattern, source: 'Dynamic (Confirmed)', }); diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 7d80b41893e..3acf7c714d1 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -136,7 +136,7 @@ describe('createPolicyUpdater', () => { const rules = policyEngine.getRules(); const addedRule = rules.find((r) => r.toolName === toolName); expect(addedRule).toBeDefined(); - expect(addedRule?.priority).toBe(2.95); + expect(addedRule?.priority).toBe(3.95); expect(addedRule?.argsPattern).toEqual( new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`), ); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 12648fec5f6..2bd18554d02 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -5,19 +5,20 @@ # # 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): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User 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 > User > Project > 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): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index b608a879046..41f6b2205b2 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -5,19 +5,20 @@ # # 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): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User 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 > User > Project > 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): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 991424cebc1..8f1e3d33e13 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -5,19 +5,20 @@ # # 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): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User 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 > User > Project > 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): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 95c3b411f1d..174099d7eeb 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -5,19 +5,20 @@ # # 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): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User 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 > User > Project > 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): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/project-policy.test.ts b/packages/core/src/policy/project-policy.test.ts index 73018b0821d..dbbc7b759ee 100644 --- a/packages/core/src/policy/project-policy.test.ts +++ b/packages/core/src/policy/project-policy.test.ts @@ -34,7 +34,7 @@ describe('Project-Level Policies', () => { vi.doUnmock('node:fs/promises'); }); - it('should load project policies with correct priority (Tier 3)', async () => { + it('should load project policies with correct priority (Tier 2)', async () => { const projectPoliciesDir = '/mock/project/policies'; const defaultPoliciesDir = '/mock/default/policies'; @@ -88,14 +88,14 @@ priority = 10 toolName = "test_tool" decision = "deny" priority = 10 -`; // Tier 2 -> 2.010 +`; // Tier 3 -> 3.010 } if (path.includes('project.toml')) { return `[[rule]] toolName = "test_tool" decision = "allow" priority = 10 -`; // Tier 3 -> 3.010 +`; // Tier 2 -> 2.010 } if (path.includes('admin.toml')) { return `[[rule]] @@ -116,7 +116,7 @@ priority = 10 const { createPolicyEngineConfig } = await import('./config.js'); - // Test 1: Project vs User (Project should win) + // Test 1: Project vs User (User should win) const config = await createPolicyEngineConfig( {}, ApprovalMode.DEFAULT, @@ -129,8 +129,8 @@ priority = 10 // 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 projectRule = rules?.find((r) => r.priority === 2.01); + const userRule = rules?.find((r) => r.priority === 3.01); const adminRule = rules?.find((r) => r.priority === 4.01); expect(defaultRule).toBeDefined(); @@ -138,10 +138,10 @@ priority = 10 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!); + // Verify Hierarchy: Admin > User > Project > Default + expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); + expect(userRule!.priority).toBeGreaterThan(projectRule!.priority!); + expect(projectRule!.priority).toBeGreaterThan(defaultRule!.priority!); }); it('should ignore project policies if projectPoliciesDir is undefined', async () => { @@ -192,7 +192,7 @@ priority=10`, expect(rules![0].priority).toBe(1.01); }); - it('should load project policies and correctly transform to Tier 3', async () => { + it('should load project policies and correctly transform to Tier 2', async () => { const projectPoliciesDir = '/mock/project/policies'; // Mock FS @@ -236,7 +236,7 @@ priority=500`, 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); + // Project Tier (2) + 500/1000 = 2.5 + expect(rule?.priority).toBe(2.5); }); }); diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index c627f6d049d..115c758063c 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -228,14 +228,18 @@ modes = ["autoEdit"] `, ); - const getPolicyTier = (_dir: string) => 2; // Tier 2 - const result = await loadPoliciesFromToml([tempDir], getPolicyTier); - - expect(result.rules).toHaveLength(1); - expect(result.rules[0].toolName).toBe('tier2-tool'); - expect(result.rules[0].modes).toEqual(['autoEdit']); - expect(result.rules[0].source).toBe('User: tier2.toml'); - expect(result.errors).toHaveLength(0); + const getPolicyTier2 = (_dir: string) => 2; // Tier 2 + const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2); + + expect(result2.rules).toHaveLength(1); + expect(result2.rules[0].toolName).toBe('tier2-tool'); + expect(result2.rules[0].modes).toEqual(['autoEdit']); + expect(result2.rules[0].source).toBe('Project: tier2.toml'); + + const getPolicyTier3 = (_dir: string) => 3; // Tier 3 + const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); + expect(result3.rules[0].source).toBe('User: tier2.toml'); + expect(result3.errors).toHaveLength(0); }); it('should handle TOML parse errors', async () => { diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index 022bddc0481..b23128a990f 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -127,8 +127,8 @@ export interface PolicyLoadResult { */ function getTierName(tier: number): 'default' | 'user' | 'project' | 'admin' { if (tier === 1) return 'default'; - if (tier === 2) return 'user'; - if (tier === 3) return 'project'; + if (tier === 2) return 'project'; + if (tier === 3) return 'user'; if (tier === 4) return 'admin'; return 'default'; } From 3ef8cfca567fd946e9775d431419c6f028264d30 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 13 Feb 2026 11:24:12 -0800 Subject: [PATCH 04/14] fix: Update test expectations to match createPolicyEngineConfig signature changes from rebase --- packages/cli/src/config/config.test.ts | 2 + packages/core/src/policy/config.ts | 2 +- .../core/src/policy/project-policy.test.ts | 70 ++++++++++++++++--- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c679ae2db1a..c4c3f351d7f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3262,6 +3262,8 @@ describe('Policy Engine Integration in loadCliConfig', () => { policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'], }), expect.anything(), + undefined, + expect.anything(), ); }); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 6a0b6c4145a..780b2da1212 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -67,7 +67,7 @@ export function getPolicyDirectories( } else { dirs.push(Storage.getUserPoliciesDir()); } - + // Project Tier (third highest) if (projectPoliciesDir) { dirs.push(projectPoliciesDir); diff --git a/packages/core/src/policy/project-policy.test.ts b/packages/core/src/policy/project-policy.test.ts index dbbc7b759ee..b3592f71e52 100644 --- a/packages/core/src/policy/project-policy.test.ts +++ b/packages/core/src/policy/project-policy.test.ts @@ -44,10 +44,20 @@ describe('Project-Level Policies', () => { 'node:fs/promises', ); + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + // 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')) + if (normalizedPath.endsWith('default/policies')) return [ { name: 'default.toml', @@ -55,11 +65,11 @@ describe('Project-Level Policies', () => { isDirectory: () => false, }, ] as unknown as Awaited>; - if (normalizedPath.includes('user')) + if (normalizedPath.endsWith('user/policies')) return [ { name: 'user.toml', isFile: () => true, isDirectory: () => false }, ] as unknown as Awaited>; - if (normalizedPath.includes('project')) + if (normalizedPath.endsWith('project/policies')) return [ { name: 'project.toml', @@ -67,7 +77,7 @@ describe('Project-Level Policies', () => { isDirectory: () => false, }, ] as unknown as Awaited>; - if (normalizedPath.includes('system')) + if (normalizedPath.endsWith('system/policies')) return [ { name: 'admin.toml', isFile: () => true, isDirectory: () => false }, ] as unknown as Awaited>; @@ -109,9 +119,15 @@ priority = 10 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, })); const { createPolicyEngineConfig } = await import('./config.js'); @@ -152,8 +168,20 @@ priority = 10 await vi.importActual( 'node:fs/promises', ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + const mockReaddir = vi.fn(async (path: string) => { - if (path.includes('default')) + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) return [ { name: 'default.toml', @@ -172,9 +200,15 @@ priority=10`, 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, })); const { createPolicyEngineConfig } = await import('./config.js'); @@ -200,8 +234,20 @@ priority=10`, await vi.importActual( 'node:fs/promises', ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + const mockReaddir = vi.fn(async (path: string) => { - if (path.includes('project')) + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('project/policies')) return [ { name: 'project.toml', @@ -220,9 +266,15 @@ priority=500`, 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, })); const { createPolicyEngineConfig } = await import('./config.js'); From 7166748d4b6bba51fd6d2fd2f6378011b27f13d7 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 13 Feb 2026 15:24:54 -0800 Subject: [PATCH 05/14] feat(policy): implement project policy integrity verification Adds a security mechanism to detect and prompt for confirmation when project-level policies are added or modified. This prevents unauthorized policy changes from being applied silently. - PolicyIntegrityManager calculates and persists policy directory hashes. - Config integrates integrity checks during startup. - PolicyUpdateDialog prompts users in interactive mode. - --accept-changed-policies flag supports non-interactive workflows. - toml-loader refactored to expose file reading logic. --- packages/cli/src/config/config.ts | 58 +++- packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 40 +++ .../cli/src/ui/components/DialogManager.tsx | 14 + .../src/ui/components/PolicyUpdateDialog.tsx | 127 ++++++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 2 + .../cli/src/ui/contexts/UIStateContext.tsx | 4 + packages/core/src/config/config.ts | 19 ++ packages/core/src/config/storage.ts | 4 + packages/core/src/index.ts | 1 + packages/core/src/policy/integrity.test.ts | 306 ++++++++++++++++++ packages/core/src/policy/integrity.ts | 149 +++++++++ packages/core/src/policy/toml-loader.ts | 75 +++-- 14 files changed, 776 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/ui/components/PolicyUpdateDialog.tsx create mode 100644 packages/core/src/policy/integrity.test.ts create mode 100644 packages/core/src/policy/integrity.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2d3dcbe8815..545eea3ec5b 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -44,6 +44,9 @@ import { type HookDefinition, type HookEventName, type OutputFormat, + PolicyIntegrityManager, + IntegrityStatus, + type PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type Settings, @@ -95,6 +98,7 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + acceptChangedPolicies: boolean | undefined; } export async function parseArguments( @@ -286,6 +290,11 @@ export async function parseArguments( .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', + }) + .option('accept-changed-policies', { + type: 'boolean', + description: + 'Automatically accept changed project policies (use with caution).', }), ) // Register MCP subcommands @@ -694,8 +703,54 @@ export async function loadCliConfig( }; let projectPoliciesDir: string | undefined; + let policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; + if (trustedFolder) { - projectPoliciesDir = new Storage(cwd).getProjectPoliciesDir(); + const potentialProjectPoliciesDir = new Storage( + cwd, + ).getProjectPoliciesDir(); + const integrityManager = new PolicyIntegrityManager(); + const integrityResult = await integrityManager.checkIntegrity( + 'project', + cwd, + potentialProjectPoliciesDir, + ); + + if (integrityResult.status === IntegrityStatus.MATCH) { + projectPoliciesDir = potentialProjectPoliciesDir; + } else if ( + integrityResult.status === IntegrityStatus.NEW && + integrityResult.fileCount === 0 + ) { + // No project policies found + projectPoliciesDir = undefined; + } else { + // Policies changed or are new + if (argv.acceptChangedPolicies) { + debugLogger.warn( + 'WARNING: Project policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', + ); + await integrityManager.acceptIntegrity( + 'project', + cwd, + integrityResult.hash, + ); + projectPoliciesDir = potentialProjectPoliciesDir; + } else if (interactive) { + policyUpdateConfirmationRequest = { + scope: 'project', + identifier: cwd, + policyDir: potentialProjectPoliciesDir, + newHash: integrityResult.hash, + }; + } else { + debugLogger.warn( + 'WARNING: Project policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', + ); + } + } } const policyEngineConfig = await createPolicyEngineConfig( @@ -765,6 +820,7 @@ export async function loadCliConfig( coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, policyEngineConfig, + policyUpdateConfirmationRequest, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 976d832abd6..16f349f801e 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + acceptChangedPolicies: undefined, }); await act(async () => { diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 2375a0fba19..f6a2648857a 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -506,6 +506,7 @@ const mockUIActions: UIActions = { vimHandleInput: vi.fn(), handleIdePromptComplete: vi.fn(), handleFolderTrustSelect: vi.fn(), + handlePolicyUpdateSelect: vi.fn(), setConstrainHeight: vi.fn(), onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 08bae449595..257fed847b1 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -81,6 +81,7 @@ import { CoreToolCallStatus, generateSteeringAckMessage, buildUserSteeringHintPrompt, + PolicyIntegrityManager, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -153,6 +154,7 @@ import { } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; +import { PolicyUpdateChoice } from './components/PolicyUpdateDialog.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; @@ -1438,6 +1440,35 @@ Logging in with Google... Restarting Gemini CLI to continue. const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + + const policyUpdateConfirmationRequest = + config.getPolicyUpdateConfirmationRequest(); + const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( + !!policyUpdateConfirmationRequest, + ); + const [isRestartingPolicyUpdate, setIsRestartingPolicyUpdate] = + useState(false); + + const handlePolicyUpdateSelect = useCallback( + async (choice: PolicyUpdateChoice) => { + if ( + choice === PolicyUpdateChoice.ACCEPT && + policyUpdateConfirmationRequest + ) { + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + policyUpdateConfirmationRequest.scope, + policyUpdateConfirmationRequest.identifier, + policyUpdateConfirmationRequest.newHash, + ); + setIsRestartingPolicyUpdate(true); + } else { + setIsPolicyUpdateDialogOpen(false); + } + }, + [policyUpdateConfirmationRequest], + ); + const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -1910,6 +1941,7 @@ Logging in with Google... Restarting Gemini CLI to continue. (shouldShowRetentionWarning && retentionCheckComplete) || shouldShowIdePrompt || isFolderTrustDialogOpen || + isPolicyUpdateDialogOpen || adminSettingsChanged || !!commandConfirmationRequest || !!authConsentRequest || @@ -2137,6 +2169,9 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, + isRestartingPolicyUpdate, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2259,6 +2294,9 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, + isRestartingPolicyUpdate, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2356,6 +2394,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + handlePolicyUpdateSelect, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, @@ -2440,6 +2479,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + handlePolicyUpdateSelect, setConstrainHeight, handleEscapePromptChange, refreshStatic, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index b28f5de2183..11119c12b07 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js'; import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; +import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -166,6 +167,19 @@ export const DialogManager = ({ /> ); } + if ( + uiState.isPolicyUpdateDialogOpen && + uiState.policyUpdateConfirmationRequest + ) { + return ( + + ); + } if (uiState.loopDetectionConfirmationRequest) { return ( void; + scope: string; + identifier: string; + isRestarting?: boolean; +} + +export const PolicyUpdateDialog: React.FC = ({ + onSelect, + scope, + identifier, + isRestarting, +}) => { + const [exiting, setExiting] = useState(false); + + useEffect(() => { + let timer: ReturnType; + if (isRestarting) { + timer = setTimeout(async () => { + await relaunchApp(); + }, 250); + } + return () => { + if (timer) clearTimeout(timer); + }; + }, [isRestarting]); + + const handleExit = useCallback(() => { + setExiting(true); + // Give time for the UI to render the exiting message + setTimeout(async () => { + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CANCELLATION_ERROR); + }, 100); + }, []); + + useKeypress( + (key) => { + if (key.name === 'escape') { + handleExit(); + return true; + } + return false; + }, + { isActive: !isRestarting }, + ); + + const options: Array> = [ + { + label: 'Accept and Load (Requires Restart)', + value: PolicyUpdateChoice.ACCEPT, + key: 'accept', + }, + { + label: 'Ignore (Use Default Policies)', + value: PolicyUpdateChoice.IGNORE, + key: 'ignore', + }, + ]; + + return ( + + + + + New or changed {scope} policies detected + + Location: {identifier} + + Do you want to accept and load these policies? + + + + + + {isRestarting && ( + + + Gemini CLI is restarting to apply the policy changes... + + + )} + {exiting && ( + + + A selection must be made to continue. Exiting since escape was + pressed. + + + )} + + ); +}; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af8706cfb13..afd49f4f4ee 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -8,6 +8,7 @@ import { createContext, useContext } from 'react'; import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; +import { type PolicyUpdateChoice } from '../components/PolicyUpdateDialog.js'; import { type AuthType, type EditorType, @@ -52,6 +53,7 @@ export interface UIActions { vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; handleFolderTrustSelect: (choice: FolderTrustChoice) => void; + handlePolicyUpdateSelect: (choice: PolicyUpdateChoice) => Promise; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; refreshStatic: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 2df7473b0c2..82b43d36167 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; @@ -112,6 +113,9 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + isPolicyUpdateDialogOpen: boolean; + policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; + isRestartingPolicyUpdate: boolean; isTrustedFolder: boolean | undefined; constrainHeight: boolean; showErrorDetails: boolean; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5b57a81acfa..6ceab582aa5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -379,6 +379,13 @@ export interface McpEnablementCallbacks { isFileEnabled: (serverId: string) => Promise; } +export interface PolicyUpdateConfirmationRequest { + scope: string; + identifier: string; + policyDir: string; + newHash: string; +} + export interface ConfigParameters { sessionId: string; clientVersion?: string; @@ -459,6 +466,7 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; output?: OutputSettings; disableModelRouterForAuth?: AuthType[]; continueOnFailedApiCall?: boolean; @@ -637,6 +645,9 @@ export class Config { private readonly useWriteTodos: boolean; private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; + private readonly policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; private readonly outputSettings: OutputSettings; private readonly continueOnFailedApiCall: boolean; private readonly retryFetchErrors: boolean; @@ -853,6 +864,8 @@ export class Config { approvalMode: params.approvalMode ?? params.policyEngineConfig?.approvalMode, }); + this.policyUpdateConfirmationRequest = + params.policyUpdateConfirmationRequest; this.messageBus = new MessageBus(this.policyEngine, this.debugMode); this.acknowledgedAgentsService = new AcknowledgedAgentsService(); this.skillManager = new SkillManager(); @@ -1721,6 +1734,12 @@ export class Config { return this.policyEngine.getApprovalMode(); } + getPolicyUpdateConfirmationRequest(): + | PolicyUpdateConfirmationRequest + | undefined { + return this.policyUpdateConfirmationRequest; + } + setApprovalMode(mode: ApprovalMode): void { if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) { throw new Error( diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 1eac8fc4c2a..39ee4761831 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -103,6 +103,10 @@ export class Storage { ); } + static getPolicyIntegrityStoragePath(): string { + return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json'); + } + private static getSystemConfigDir(): string { if (os.platform() === 'darwin') { return '/Library/Application Support/GeminiCli'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f82486173d..36d10d38328 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export * from './policy/types.js'; export * from './policy/policy-engine.js'; export * from './policy/toml-loader.js'; export * from './policy/config.js'; +export * from './policy/integrity.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts new file mode 100644 index 00000000000..c345914fedf --- /dev/null +++ b/packages/core/src/policy/integrity.test.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + afterEach, + beforeEach, + type Mock, +} from 'vitest'; +import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js'; + +// Mock dependencies +vi.mock('../config/storage.js', () => ({ + Storage: { + getPolicyIntegrityStoragePath: vi + .fn() + .mockReturnValue('/mock/storage/policy_integrity.json'), + }, +})); + +vi.mock('./toml-loader.js', () => ({ + readPolicyFiles: vi.fn(), +})); + +// Mock FS +const mockFs = vi.hoisted(() => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + default: mockFs, + readFile: mockFs.readFile, + writeFile: mockFs.writeFile, + mkdir: mockFs.mkdir, +})); + +describe('PolicyIntegrityManager', () => { + let integrityManager: PolicyIntegrityManager; + let readPolicyFilesMock: Mock; + + beforeEach(async () => { + vi.resetModules(); + const { readPolicyFiles } = await import('./toml-loader.js'); + readPolicyFilesMock = readPolicyFiles as Mock; + integrityManager = new PolicyIntegrityManager(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('checkIntegrity', () => { + it('should return NEW if no stored hash', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentA' }, + ]); + + const result = await integrityManager.checkIntegrity( + 'project', + 'id', + '/dir', + ); + expect(result.status).toBe(IntegrityStatus.NEW); + expect(result.hash).toBeDefined(); + expect(result.hash).toHaveLength(64); + expect(result.fileCount).toBe(1); + }); + + it('should return MATCH if stored hash matches', async () => { + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentA' }, + ]); + // We can't easily get the expected hash without calling private method or re-implementing logic. + // But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash. + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + const resultNew = await integrityManager.checkIntegrity( + 'project', + 'id', + '/dir', + ); + const currentHash = resultNew.hash; + + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + 'project:id': currentHash, + }), + ); + + const result = await integrityManager.checkIntegrity( + 'project', + 'id', + '/dir', + ); + expect(result.status).toBe(IntegrityStatus.MATCH); + expect(result.hash).toBe(currentHash); + expect(result.fileCount).toBe(1); + }); + + it('should return MISMATCH if stored hash differs', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentA' }, + ]); + const resultNew = await integrityManager.checkIntegrity( + 'project', + 'id', + '/dir', + ); + const currentHash = resultNew.hash; + + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + 'project:id': 'different_hash', + }), + ); + + const result = await integrityManager.checkIntegrity( + 'project', + 'id', + '/dir', + ); + expect(result.status).toBe(IntegrityStatus.MISMATCH); + expect(result.hash).toBe(currentHash); + expect(result.fileCount).toBe(1); + }); + + it('should result in different hash if filename changes', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentA' }, + ]); + const result1 = await integrityManager.checkIntegrity( + 'project', + 'id', + '/project/policies', + ); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/b.toml', content: 'contentA' }, + ]); + const result2 = await integrityManager.checkIntegrity( + 'project', + 'id', + '/project/policies', + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should result in different hash if content changes', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentA' }, + ]); + const result1 = await integrityManager.checkIntegrity( + 'project', + 'id', + '/project/policies', + ); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentB' }, + ]); + const result2 = await integrityManager.checkIntegrity( + 'project', + 'id', + '/project/policies', + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should be deterministic (sort order)', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/project/policies/b.toml', content: 'contentB' }, + ]); + const result1 = await integrityManager.checkIntegrity( + 'project', + 'id', + '/project/policies', + ); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/project/policies/b.toml', content: 'contentB' }, + { path: '/project/policies/a.toml', content: 'contentA' }, + ]); + const result2 = await integrityManager.checkIntegrity( + 'project', + 'id', + '/project/policies', + ); + + expect(result1.hash).toBe(result2.hash); + }); + + it('should handle multiple projects correctly', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + + // First, get hashes for two different projects + readPolicyFilesMock.mockResolvedValue([ + { path: '/dirA/p.toml', content: 'contentA' }, + ]); + const { hash: hashA } = await integrityManager.checkIntegrity( + 'project', + 'idA', + '/dirA', + ); + + readPolicyFilesMock.mockResolvedValue([ + { path: '/dirB/p.toml', content: 'contentB' }, + ]); + const { hash: hashB } = await integrityManager.checkIntegrity( + 'project', + 'idB', + '/dirB', + ); + + // Now mock storage with both + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + 'project:idA': hashA, + 'project:idB': 'oldHashB', // Different from hashB + }), + ); + + // Project A should match + readPolicyFilesMock.mockResolvedValue([ + { path: '/dirA/p.toml', content: 'contentA' }, + ]); + const resultA = await integrityManager.checkIntegrity( + 'project', + 'idA', + '/dirA', + ); + expect(resultA.status).toBe(IntegrityStatus.MATCH); + expect(resultA.hash).toBe(hashA); + + // Project B should mismatch + readPolicyFilesMock.mockResolvedValue([ + { path: '/dirB/p.toml', content: 'contentB' }, + ]); + const resultB = await integrityManager.checkIntegrity( + 'project', + 'idB', + '/dirB', + ); + expect(resultB.status).toBe(IntegrityStatus.MISMATCH); + expect(resultB.hash).toBe(hashB); + }); + }); + + describe('acceptIntegrity', () => { + it('should save the hash to storage', async () => { + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // Start empty + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + await integrityManager.acceptIntegrity('project', 'id', 'hash123'); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + '/mock/storage/policy_integrity.json', + JSON.stringify({ 'project:id': 'hash123' }, null, 2), + 'utf-8', + ); + }); + + it('should update existing hash', async () => { + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + 'other:id': 'otherhash', + }), + ); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + await integrityManager.acceptIntegrity('project', 'id', 'hash123'); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + '/mock/storage/policy_integrity.json', + JSON.stringify( + { + 'other:id': 'otherhash', + 'project:id': 'hash123', + }, + null, + 2, + ), + 'utf-8', + ); + }); + }); +}); diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts new file mode 100644 index 00000000000..d9661853ae1 --- /dev/null +++ b/packages/core/src/policy/integrity.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { readPolicyFiles } from './toml-loader.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +export enum IntegrityStatus { + MATCH = 'MATCH', + MISMATCH = 'MISMATCH', + NEW = 'NEW', +} + +export interface IntegrityResult { + status: IntegrityStatus; + hash: string; + fileCount: number; +} + +interface StoredIntegrityData { + [key: string]: string; // key = scope:identifier, value = hash +} + +export class PolicyIntegrityManager { + /** + * Checks the integrity of policies in a given directory against the stored hash. + * + * @param scope The scope of the policy (e.g., 'project', 'user'). + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param policyDir The directory containing the policy files. + * @returns IntegrityResult indicating if the current policies match the stored hash. + */ + async checkIntegrity( + scope: string, + identifier: string, + policyDir: string, + ): Promise { + const { hash: currentHash, fileCount } = + await PolicyIntegrityManager.calculateIntegrityHash(policyDir); + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + const storedHash = storedData[key]; + + if (!storedHash) { + return { status: IntegrityStatus.NEW, hash: currentHash, fileCount }; + } + + if (storedHash === currentHash) { + return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount }; + } + + return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount }; + } + + /** + * Accepts and persists the current integrity hash for a given policy scope. + * + * @param scope The scope of the policy. + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param hash The hash to persist. + */ + async acceptIntegrity( + scope: string, + identifier: string, + hash: string, + ): Promise { + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + storedData[key] = hash; + await this.saveIntegrityData(storedData); + } + + /** + * Calculates a SHA-256 hash of all policy files in the directory. + * The hash includes the relative file path and content to detect renames and modifications. + * + * @param policyDir The directory containing the policy files. + * @returns The calculated hash and file count + */ + private static async calculateIntegrityHash( + policyDir: string, + ): Promise<{ hash: string; fileCount: number }> { + try { + const files = await readPolicyFiles(policyDir); + + // Sort files by path to ensure deterministic hashing + files.sort((a, b) => a.path.localeCompare(b.path)); + + const hash = crypto.createHash('sha256'); + + for (const file of files) { + const relativePath = path.relative(policyDir, file.path); + // Include relative path and content in the hash + hash.update(relativePath); + hash.update('\0'); // Separator + hash.update(file.content); + hash.update('\0'); // Separator + } + + return { hash: hash.digest('hex'), fileCount: files.length }; + } catch (error) { + debugLogger.error('Failed to calculate policy integrity hash', error); + // Return a unique hash (random) to force a mismatch if calculation fails? + // Or throw? Throwing is better so we don't accidentally accept/deny corrupted state. + throw error; + } + } + + private getIntegrityKey(scope: string, identifier: string): string { + return `${scope}:${identifier}`; + } + + private async loadIntegrityData(): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + const content = await fs.readFile(storagePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as StoredIntegrityData; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as Record)['code'] === 'ENOENT' + ) { + return {}; + } + debugLogger.error('Failed to load policy integrity data', error); + return {}; + } + } + + private async saveIntegrityData(data: StoredIntegrityData): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + await fs.mkdir(path.dirname(storagePath), { recursive: true }); + await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + debugLogger.error('Failed to save policy integrity data', error); + throw error; + } + } +} diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index b23128a990f..53b0c6b3fdc 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -122,6 +122,53 @@ export interface PolicyLoadResult { errors: PolicyFileError[]; } +export interface PolicyFile { + path: string; + content: string; +} + +/** + * Reads policy files from a directory or a single file. + * + * @param policyPath Path to a directory or a .toml file. + * @returns Array of PolicyFile objects. + */ +export async function readPolicyFiles( + policyPath: string, +): Promise { + let filesToLoad: string[] = []; + let baseDir = ''; + + try { + const stats = await fs.stat(policyPath); + if (stats.isDirectory()) { + baseDir = policyPath; + const dirEntries = await fs.readdir(policyPath, { withFileTypes: true }); + filesToLoad = dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) + .map((entry) => entry.name); + } else if (stats.isFile() && policyPath.endsWith('.toml')) { + baseDir = path.dirname(policyPath); + filesToLoad = [path.basename(policyPath)]; + } + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const error = e as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + return []; + } + throw error; + } + + const results: PolicyFile[] = []; + for (const file of filesToLoad) { + const filePath = path.join(baseDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + results.push({ path: filePath, content }); + } + return results; +} + /** * Converts a tier number to a human-readable tier name. */ @@ -227,30 +274,13 @@ export async function loadPoliciesFromToml( const tier = getPolicyTier(p); const tierName = getTierName(tier); - let filesToLoad: string[] = []; - let baseDir = ''; + let policyFiles: PolicyFile[] = []; try { - 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. + policyFiles = await readPolicyFiles(p); } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const error = e as NodeJS.ErrnoException; - if (error.code === 'ENOENT') { - // Path doesn't exist, skip it (not an error) - continue; - } errors.push({ filePath: p, fileName: path.basename(p), @@ -262,13 +292,10 @@ export async function loadPoliciesFromToml( continue; } - for (const file of filesToLoad) { - const filePath = path.join(baseDir, file); + for (const { path: filePath, content: fileContent } of policyFiles) { + const file = path.basename(filePath); try { - // Read file - const fileContent = await fs.readFile(filePath, 'utf-8'); - // Parse TOML let parsed: unknown; try { From 3a2fd2a0df24b8674429749339375d16c2774527 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 13 Feb 2026 16:11:42 -0800 Subject: [PATCH 06/14] fix(policy): refactor policy dialog to remove process.exit and fix integration tests - Refactored `PolicyUpdateDialog` to remove side effects (`process.exit`, `relaunchApp`) and delegate logic to parent. - Updated `AppContainer` to handle relaunch logic. - Added comprehensive unit tests for `PolicyUpdateDialog`. - Fixed `project-policy-cli.test.ts` to correctly mock `PolicyIntegrityManager`. - Fixed typo in `packages/core/src/policy/config.ts`. --- .../cli/src/config/project-policy-cli.test.ts | 8 ++ packages/cli/src/ui/AppContainer.tsx | 6 +- .../ui/components/PolicyUpdateDialog.test.tsx | 120 ++++++++++++++++++ .../src/ui/components/PolicyUpdateDialog.tsx | 40 +----- packages/core/src/policy/config.ts | 2 +- 5 files changed, 136 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx diff --git a/packages/cli/src/config/project-policy-cli.test.ts b/packages/cli/src/config/project-policy-cli.test.ts index 6d7d5d5ac0e..b219eafee1e 100644 --- a/packages/cli/src/config/project-policy-cli.test.ts +++ b/packages/cli/src/config/project-policy-cli.test.ts @@ -32,6 +32,14 @@ vi.mock('@google/gemini-cli-core', async () => { checkers: [], }), getVersion: vi.fn().mockResolvedValue('test-version'), + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + checkIntegrity: vi.fn().mockResolvedValue({ + status: 'match', // IntegrityStatus.MATCH + hash: 'test-hash', + fileCount: 1, + }), + })), + IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, }; }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 257fed847b1..3e2a54c7c85 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -122,7 +122,7 @@ import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; +import { RELAUNCH_EXIT_CODE, relaunchApp } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; @@ -1462,6 +1462,10 @@ Logging in with Google... Restarting Gemini CLI to continue. policyUpdateConfirmationRequest.newHash, ); setIsRestartingPolicyUpdate(true); + // Give time for the UI to render the restarting message + setTimeout(async () => { + await relaunchApp(); + }, 250); } else { setIsPolicyUpdateDialogOpen(false); } diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx new file mode 100644 index 00000000000..ffc49e443b7 --- /dev/null +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { + PolicyUpdateDialog, + PolicyUpdateChoice, +} from './PolicyUpdateDialog.js'; + +describe('PolicyUpdateDialog', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with default props', () => { + const onSelect = vi.fn(); + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('New or changed project policies detected'); + expect(output).toContain('Location: /test/path'); + expect(output).toContain('Accept and Load'); + expect(output).toContain('Ignore'); + }); + + it('calls onSelect with ACCEPT when accept option is chosen', async () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + // Accept is the first option, so pressing enter should select it + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.ACCEPT); + }); + }); + + it('calls onSelect with IGNORE when ignore option is chosen', async () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + // Move down to Ignore option + await act(async () => { + stdin.write('\x1B[B'); // Down arrow + }); + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.IGNORE); + }); + }); + + it('calls onSelect with IGNORE when Escape is pressed', async () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x1B'); // Escape key + }); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.IGNORE); + }); + }); + + it('displays restarting message when isRestarting is true', () => { + const onSelect = vi.fn(); + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain( + 'Gemini CLI is restarting to apply the policy changes...', + ); + }); +}); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx index c7116c5a000..c05044bd9c4 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -1,20 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import type React from 'react'; -import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import * as process from 'node:process'; -import { relaunchApp } from '../../utils/processUtils.js'; -import { runExitCleanup } from '../../utils/cleanup.js'; -import { ExitCodes } from '@google/gemini-cli-core'; export enum PolicyUpdateChoice { ACCEPT = 'accept', @@ -34,33 +29,10 @@ export const PolicyUpdateDialog: React.FC = ({ identifier, isRestarting, }) => { - const [exiting, setExiting] = useState(false); - - useEffect(() => { - let timer: ReturnType; - if (isRestarting) { - timer = setTimeout(async () => { - await relaunchApp(); - }, 250); - } - return () => { - if (timer) clearTimeout(timer); - }; - }, [isRestarting]); - - const handleExit = useCallback(() => { - setExiting(true); - // Give time for the UI to render the exiting message - setTimeout(async () => { - await runExitCleanup(); - process.exit(ExitCodes.FATAL_CANCELLATION_ERROR); - }, 100); - }, []); - useKeypress( (key) => { if (key.name === 'escape') { - handleExit(); + onSelect(PolicyUpdateChoice.IGNORE); return true; } return false; @@ -114,14 +86,6 @@ export const PolicyUpdateDialog: React.FC = ({ )} - {exiting && ( - - - A selection must be made to continue. Exiting since escape was - pressed. - - - )} ); }; diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 780b2da1212..6acb59f70b2 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -61,7 +61,7 @@ export function getPolicyDirectories( // Admin tier (highest priority) dirs.push(Storage.getSystemPoliciesDir()); - // User tier (second higheset priority) + // User tier (second highest priority) if (policyPaths && policyPaths.length > 0) { dirs.push(...policyPaths); } else { From b9245da7d1d8c4aae5a3e71e3fdafaa24b63b614 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 13 Feb 2026 16:36:48 -0800 Subject: [PATCH 07/14] test(cli): improve project policy config test coverage Updates config.test.ts to fix createPolicyEngineConfig mock expectations and expands project-policy-cli.test.ts to cover integrity check scenarios (NEW, MISMATCH) and interactive confirmation flows. --- packages/cli/src/config/config.test.ts | 6 +- .../cli/src/config/project-policy-cli.test.ts | 182 +++++++++++++++++- 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c4c3f351d7f..ce7ce1d5909 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3218,7 +3218,7 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), expect.anything(), undefined, - expect.anything(), + undefined, ); }); @@ -3241,7 +3241,7 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), expect.anything(), undefined, - expect.anything(), + undefined, ); }); @@ -3263,7 +3263,7 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), expect.anything(), undefined, - expect.anything(), + undefined, ); }); }); diff --git a/packages/cli/src/config/project-policy-cli.test.ts b/packages/cli/src/config/project-policy-cli.test.ts index b219eafee1e..b7b8b94ba9b 100644 --- a/packages/cli/src/config/project-policy-cli.test.ts +++ b/packages/cli/src/config/project-policy-cli.test.ts @@ -10,12 +10,16 @@ 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'; +import { debugLogger } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn(), })); +const mockCheckIntegrity = vi.fn(); +const mockAcceptIntegrity = vi.fn(); + vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual( '@google/gemini-cli-core', @@ -33,13 +37,15 @@ vi.mock('@google/gemini-cli-core', async () => { }), getVersion: vi.fn().mockResolvedValue('test-version'), PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ - checkIntegrity: vi.fn().mockResolvedValue({ - status: 'match', // IntegrityStatus.MATCH - hash: 'test-hash', - fileCount: 1, - }), + checkIntegrity: mockCheckIntegrity, + acceptIntegrity: mockAcceptIntegrity, })), IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + }, + isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive }; }); @@ -48,6 +54,13 @@ describe('Project-Level Policy CLI Integration', () => { beforeEach(() => { vi.clearAllMocks(); + // Default to MATCH for existing tests + mockCheckIntegrity.mockResolvedValue({ + status: 'match', + hash: 'test-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); }); it('should have getProjectPoliciesDir on Storage class', () => { @@ -67,12 +80,10 @@ describe('Project-Level Policy CLI Integration', () => { 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 + undefined, expect.stringContaining(path.join('.gemini', 'policies')), ); }); @@ -88,7 +99,160 @@ describe('Project-Level Policy CLI Integration', () => { 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, + ); + }); + + it('should NOT pass projectPoliciesDir if integrity is NEW but fileCount is 0', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'hash', + fileCount: 0, + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + undefined, + ); + }); + + it('should warn and NOT pass projectPoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive + + const settings = createTestMergedSettings(); + const argv = { prompt: 'do something' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Project policies changed or are new'), + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + undefined, // Should NOT load policies + ); + }); + + it('should accept policies if --accept-changed-policies is passed', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + + const settings = createTestMergedSettings(); + const argv = { acceptChangedPolicies: true } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'project', + MOCK_CWD, + 'new-hash', + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.stringContaining(path.join('.gemini', 'policies')), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'project', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + // In interactive mode without accept flag, it waits for user confirmation (handled by UI), + // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. + // The UI will handle the confirmation and reload/update. + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + undefined, + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'new-hash', + fileCount: 5, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'project', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.anything(), expect.anything(), From a4143be09a869fd4265121d4c16c9a84b82b6f36 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Tue, 17 Feb 2026 11:08:10 -0800 Subject: [PATCH 08/14] refactor(policy): rename "Project" policies to "Workspace" policies Updates the terminology and configuration for the intermediate policy tier from "Project" to "Workspace" to better align with the Gemini CLI ecosystem. Key changes: - Renamed `PROJECT_POLICY_TIER` to `WORKSPACE_POLICY_TIER`. - Renamed `getProjectPoliciesDir` to `getWorkspacePoliciesDir`. - Updated integrity scope from `project` to `workspace`. - Updated UI dialogs and documentation. - Renamed related test files. --- docs/reference/policy-engine.md | 30 +++---- packages/cli/src/config/config.ts | 32 ++++---- packages/cli/src/config/policy.ts | 4 +- ...i.test.ts => workspace-policy-cli.test.ts} | 24 +++--- .../ui/components/PolicyUpdateDialog.test.tsx | 12 +-- packages/core/src/config/storage.ts | 2 +- packages/core/src/policy/config.ts | 32 ++++---- packages/core/src/policy/integrity.test.ts | 80 +++++++++---------- packages/core/src/policy/policies/plan.toml | 4 +- .../core/src/policy/policies/read-only.toml | 4 +- packages/core/src/policy/policies/write.toml | 4 +- packages/core/src/policy/policies/yolo.toml | 4 +- packages/core/src/policy/toml-loader.test.ts | 2 +- packages/core/src/policy/toml-loader.ts | 6 +- ...olicy.test.ts => workspace-policy.test.ts} | 42 +++++----- 15 files changed, 141 insertions(+), 141 deletions(-) rename packages/cli/src/config/{project-policy-cli.test.ts => workspace-policy-cli.test.ts} (89%) rename packages/core/src/policy/{project-policy.test.ts => workspace-policy.test.ts} (86%) diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 352c34be99f..2106b751c91 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -92,12 +92,12 @@ rule with the highest priority wins**. To provide a clear hierarchy, policies are organized into three tiers. Each tier has a designated number that forms the base of the final priority calculation. -| Tier | Base | Description | -| :------ | :--- | :------------------------------------------------------------------------- | -| Default | 1 | Built-in policies that ship with the Gemini CLI. | -| Project | 2 | Policies defined in the current project's configuration directory. | -| User | 3 | Custom policies defined by the user. | -| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Tier | Base | Description | +| :-------- | :--- | :------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with the Gemini CLI. | +| Workspace | 2 | Policies defined in the current workspace's configuration directory. | +| User | 3 | Custom policies defined by the user. | +| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -106,15 +106,15 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User, Project, and Default policies. -- User policies override Project and Default policies. -- Project policies override Default policies. +- Admin policies always override User, Workspace, and Default policies. +- User policies override Workspace and Default policies. +- Workspace policies override Default policies. - You can still order rules within a single tier with fine-grained control. For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. -- A `priority: 10` rule in a Project policy file becomes `2.010`. +- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`. - A `priority: 100` rule in a User policy file becomes `3.100`. - A `priority: 20` rule in an Admin policy file becomes `4.020`. @@ -159,11 +159,11 @@ User, and (if configured) Admin directories. ### Policy locations -| Tier | Type | Location | -| :---------- | :----- | :-------------------------------------- | -| **User** | Custom | `~/.gemini/policies/*.toml` | -| **Project** | Custom | `$PROJECT_ROOT/.gemini/policies/*.toml` | -| **Admin** | System | _See below (OS specific)_ | +| Tier | Type | Location | +| :------------ | :----- | :---------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 545eea3ec5b..e021a0048a8 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -294,7 +294,7 @@ export async function parseArguments( .option('accept-changed-policies', { type: 'boolean', description: - 'Automatically accept changed project policies (use with caution).', + 'Automatically accept changed workspace policies (use with caution).', }), ) // Register MCP subcommands @@ -702,52 +702,52 @@ export async function loadCliConfig( policyPaths: argv.policy, }; - let projectPoliciesDir: string | undefined; + let workspacePoliciesDir: string | undefined; let policyUpdateConfirmationRequest: | PolicyUpdateConfirmationRequest | undefined; if (trustedFolder) { - const potentialProjectPoliciesDir = new Storage( + const potentialWorkspacePoliciesDir = new Storage( cwd, - ).getProjectPoliciesDir(); + ).getWorkspacePoliciesDir(); const integrityManager = new PolicyIntegrityManager(); const integrityResult = await integrityManager.checkIntegrity( - 'project', + 'workspace', cwd, - potentialProjectPoliciesDir, + potentialWorkspacePoliciesDir, ); if (integrityResult.status === IntegrityStatus.MATCH) { - projectPoliciesDir = potentialProjectPoliciesDir; + workspacePoliciesDir = potentialWorkspacePoliciesDir; } else if ( integrityResult.status === IntegrityStatus.NEW && integrityResult.fileCount === 0 ) { - // No project policies found - projectPoliciesDir = undefined; + // No workspace policies found + workspacePoliciesDir = undefined; } else { // Policies changed or are new if (argv.acceptChangedPolicies) { debugLogger.warn( - 'WARNING: Project policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', + 'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', ); await integrityManager.acceptIntegrity( - 'project', + 'workspace', cwd, integrityResult.hash, ); - projectPoliciesDir = potentialProjectPoliciesDir; + workspacePoliciesDir = potentialWorkspacePoliciesDir; } else if (interactive) { policyUpdateConfirmationRequest = { - scope: 'project', + scope: 'workspace', identifier: cwd, - policyDir: potentialProjectPoliciesDir, + policyDir: potentialWorkspacePoliciesDir, newHash: integrityResult.hash, }; } else { debugLogger.warn( - 'WARNING: Project policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', + 'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', ); } } @@ -756,7 +756,7 @@ export async function loadCliConfig( const policyEngineConfig = await createPolicyEngineConfig( effectiveSettings, approvalMode, - projectPoliciesDir, + workspacePoliciesDir, ); policyEngineConfig.nonInteractive = !interactive; diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 145a466a88f..5d48271edea 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -18,7 +18,7 @@ import { type Settings } from './settings.js'; export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, - projectPoliciesDir?: string, + workspacePoliciesDir?: string, ): Promise { // Explicitly construct PolicySettings from Settings to ensure type safety // and avoid accidental leakage of other settings properties. @@ -33,7 +33,7 @@ export async function createPolicyEngineConfig( policySettings, approvalMode, undefined, - projectPoliciesDir, + workspacePoliciesDir, ); } diff --git a/packages/cli/src/config/project-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts similarity index 89% rename from packages/cli/src/config/project-policy-cli.test.ts rename to packages/cli/src/config/workspace-policy-cli.test.ts index b7b8b94ba9b..e6ad5bfc4c1 100644 --- a/packages/cli/src/config/project-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -49,7 +49,7 @@ vi.mock('@google/gemini-cli-core', async () => { }; }); -describe('Project-Level Policy CLI Integration', () => { +describe('Workspace-Level Policy CLI Integration', () => { const MOCK_CWD = process.cwd(); beforeEach(() => { @@ -63,13 +63,13 @@ describe('Project-Level Policy CLI Integration', () => { vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); }); - it('should have getProjectPoliciesDir on Storage class', () => { + it('should have getWorkspacePoliciesDir on Storage class', () => { const storage = new ServerConfig.Storage(MOCK_CWD); - expect(storage.getProjectPoliciesDir).toBeDefined(); - expect(typeof storage.getProjectPoliciesDir).toBe('function'); + expect(storage.getWorkspacePoliciesDir).toBeDefined(); + expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); }); - it('should pass projectPoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { + it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -88,7 +88,7 @@ describe('Project-Level Policy CLI Integration', () => { ); }); - it('should NOT pass projectPoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { + it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', @@ -107,7 +107,7 @@ describe('Project-Level Policy CLI Integration', () => { ); }); - it('should NOT pass projectPoliciesDir if integrity is NEW but fileCount is 0', async () => { + it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -131,7 +131,7 @@ describe('Project-Level Policy CLI Integration', () => { ); }); - it('should warn and NOT pass projectPoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + it('should warn and NOT pass workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -149,7 +149,7 @@ describe('Project-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Project policies changed or are new'), + expect.stringContaining('Workspace policies changed or are new'), ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.anything(), @@ -176,7 +176,7 @@ describe('Project-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(mockAcceptIntegrity).toHaveBeenCalledWith( - 'project', + 'workspace', MOCK_CWD, 'new-hash', ); @@ -211,7 +211,7 @@ describe('Project-Level Policy CLI Integration', () => { }); expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ - scope: 'project', + scope: 'workspace', identifier: MOCK_CWD, policyDir: expect.stringContaining(path.join('.gemini', 'policies')), newHash: 'new-hash', @@ -247,7 +247,7 @@ describe('Project-Level Policy CLI Integration', () => { }); expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ - scope: 'project', + scope: 'workspace', identifier: MOCK_CWD, policyDir: expect.stringContaining(path.join('.gemini', 'policies')), newHash: 'new-hash', diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx index ffc49e443b7..51755648865 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx @@ -23,14 +23,14 @@ describe('PolicyUpdateDialog', () => { const { lastFrame } = renderWithProviders( , ); const output = lastFrame(); - expect(output).toContain('New or changed project policies detected'); + expect(output).toContain('New or changed workspace policies detected'); expect(output).toContain('Location: /test/path'); expect(output).toContain('Accept and Load'); expect(output).toContain('Ignore'); @@ -41,7 +41,7 @@ describe('PolicyUpdateDialog', () => { const { stdin } = renderWithProviders( , @@ -62,7 +62,7 @@ describe('PolicyUpdateDialog', () => { const { stdin } = renderWithProviders( , @@ -86,7 +86,7 @@ describe('PolicyUpdateDialog', () => { const { stdin } = renderWithProviders( , @@ -106,7 +106,7 @@ describe('PolicyUpdateDialog', () => { const { lastFrame } = renderWithProviders( , diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 39ee4761831..3a079f3b7e6 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -150,7 +150,7 @@ export class Storage { return path.join(tempDir, identifier); } - getProjectPoliciesDir(): string { + getWorkspacePoliciesDir(): string { return path.join(this.getGeminiDir(), 'policies'); } diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 6acb59f70b2..a9414d65b62 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -38,7 +38,7 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies'); // Policy tier constants for priority calculation export const DEFAULT_POLICY_TIER = 1; -export const PROJECT_POLICY_TIER = 2; +export const WORKSPACE_POLICY_TIER = 2; export const USER_POLICY_TIER = 3; export const ADMIN_POLICY_TIER = 4; @@ -49,12 +49,12 @@ export const ADMIN_POLICY_TIER = 4; * @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. + * @param workspacePoliciesDir Optional path to a directory containing workspace policies. */ export function getPolicyDirectories( defaultPoliciesDir?: string, policyPaths?: string[], - projectPoliciesDir?: string, + workspacePoliciesDir?: string, ): string[] { const dirs = []; @@ -68,9 +68,9 @@ export function getPolicyDirectories( dirs.push(Storage.getUserPoliciesDir()); } - // Project Tier (third highest) - if (projectPoliciesDir) { - dirs.push(projectPoliciesDir); + // Workspace Tier (third highest) + if (workspacePoliciesDir) { + dirs.push(workspacePoliciesDir); } // Default tier (lowest priority) @@ -80,13 +80,13 @@ export function getPolicyDirectories( } /** - * Determines the policy tier (1=default, 2=user, 3=project, 4=admin) for a given directory. + * Determines the policy tier (1=default, 2=user, 3=workspace, 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, + workspacePoliciesDir?: string, ): number { const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir(); @@ -108,10 +108,10 @@ export function getPolicyTier( return USER_POLICY_TIER; } if ( - projectPoliciesDir && - normalizedDir === path.resolve(projectPoliciesDir) + workspacePoliciesDir && + normalizedDir === path.resolve(workspacePoliciesDir) ) { - return PROJECT_POLICY_TIER; + return WORKSPACE_POLICY_TIER; } if (normalizedDir === normalizedAdmin) { return ADMIN_POLICY_TIER; @@ -167,12 +167,12 @@ export async function createPolicyEngineConfig( settings: PolicySettings, approvalMode: ApprovalMode, defaultPoliciesDir?: string, - projectPoliciesDir?: string, + workspacePoliciesDir?: string, ): Promise { const policyDirs = getPolicyDirectories( defaultPoliciesDir, settings.policyPaths, - projectPoliciesDir, + workspacePoliciesDir, ); const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs); @@ -186,7 +186,7 @@ export async function createPolicyEngineConfig( checkers: tomlCheckers, errors, } = await loadPoliciesFromToml(securePolicyDirs, (p) => { - const tier = getPolicyTier(p, defaultPoliciesDir, projectPoliciesDir); + const tier = getPolicyTier(p, defaultPoliciesDir, workspacePoliciesDir); // If it's a user-provided path that isn't already categorized as ADMIN, // treat it as USER tier. @@ -222,11 +222,11 @@ export async function createPolicyEngineConfig( // // Priority bands (tiers): // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) - // - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) // - User 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 > Project > Default hierarchy is always preserved, + // This ensures Admin > User > Workspace > Default hierarchy is always preserved, // while allowing user-specified priorities to work within each tier. // // Settings-based and dynamic rules (all in user tier 3.x): diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts index c345914fedf..a289c513d0b 100644 --- a/packages/core/src/policy/integrity.test.ts +++ b/packages/core/src/policy/integrity.test.ts @@ -61,11 +61,11 @@ describe('PolicyIntegrityManager', () => { it('should return NEW if no stored hash', async () => { mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, ]); const result = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', '/dir', ); @@ -77,13 +77,13 @@ describe('PolicyIntegrityManager', () => { it('should return MATCH if stored hash matches', async () => { readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, ]); // We can't easily get the expected hash without calling private method or re-implementing logic. // But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash. mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); const resultNew = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', '/dir', ); @@ -91,12 +91,12 @@ describe('PolicyIntegrityManager', () => { mockFs.readFile.mockResolvedValue( JSON.stringify({ - 'project:id': currentHash, + 'workspace:id': currentHash, }), ); const result = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', '/dir', ); @@ -108,10 +108,10 @@ describe('PolicyIntegrityManager', () => { it('should return MISMATCH if stored hash differs', async () => { mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, ]); const resultNew = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', '/dir', ); @@ -119,12 +119,12 @@ describe('PolicyIntegrityManager', () => { mockFs.readFile.mockResolvedValue( JSON.stringify({ - 'project:id': 'different_hash', + 'workspace:id': 'different_hash', }), ); const result = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', '/dir', ); @@ -137,21 +137,21 @@ describe('PolicyIntegrityManager', () => { mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, ]); const result1 = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', - '/project/policies', + '/workspace/policies', ); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/b.toml', content: 'contentA' }, + { path: '/workspace/policies/b.toml', content: 'contentA' }, ]); const result2 = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', - '/project/policies', + '/workspace/policies', ); expect(result1.hash).not.toBe(result2.hash); @@ -161,21 +161,21 @@ describe('PolicyIntegrityManager', () => { mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, ]); const result1 = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', - '/project/policies', + '/workspace/policies', ); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentB' }, + { path: '/workspace/policies/a.toml', content: 'contentB' }, ]); const result2 = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', - '/project/policies', + '/workspace/policies', ); expect(result1.hash).not.toBe(result2.hash); @@ -185,23 +185,23 @@ describe('PolicyIntegrityManager', () => { mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/a.toml', content: 'contentA' }, - { path: '/project/policies/b.toml', content: 'contentB' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/b.toml', content: 'contentB' }, ]); const result1 = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', - '/project/policies', + '/workspace/policies', ); readPolicyFilesMock.mockResolvedValue([ - { path: '/project/policies/b.toml', content: 'contentB' }, - { path: '/project/policies/a.toml', content: 'contentA' }, + { path: '/workspace/policies/b.toml', content: 'contentB' }, + { path: '/workspace/policies/a.toml', content: 'contentA' }, ]); const result2 = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'id', - '/project/policies', + '/workspace/policies', ); expect(result1.hash).toBe(result2.hash); @@ -215,7 +215,7 @@ describe('PolicyIntegrityManager', () => { { path: '/dirA/p.toml', content: 'contentA' }, ]); const { hash: hashA } = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'idA', '/dirA', ); @@ -224,7 +224,7 @@ describe('PolicyIntegrityManager', () => { { path: '/dirB/p.toml', content: 'contentB' }, ]); const { hash: hashB } = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'idB', '/dirB', ); @@ -232,8 +232,8 @@ describe('PolicyIntegrityManager', () => { // Now mock storage with both mockFs.readFile.mockResolvedValue( JSON.stringify({ - 'project:idA': hashA, - 'project:idB': 'oldHashB', // Different from hashB + 'workspace:idA': hashA, + 'workspace:idB': 'oldHashB', // Different from hashB }), ); @@ -242,7 +242,7 @@ describe('PolicyIntegrityManager', () => { { path: '/dirA/p.toml', content: 'contentA' }, ]); const resultA = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'idA', '/dirA', ); @@ -254,7 +254,7 @@ describe('PolicyIntegrityManager', () => { { path: '/dirB/p.toml', content: 'contentB' }, ]); const resultB = await integrityManager.checkIntegrity( - 'project', + 'workspace', 'idB', '/dirB', ); @@ -269,11 +269,11 @@ describe('PolicyIntegrityManager', () => { mockFs.mkdir.mockResolvedValue(undefined); mockFs.writeFile.mockResolvedValue(undefined); - await integrityManager.acceptIntegrity('project', 'id', 'hash123'); + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); expect(mockFs.writeFile).toHaveBeenCalledWith( '/mock/storage/policy_integrity.json', - JSON.stringify({ 'project:id': 'hash123' }, null, 2), + JSON.stringify({ 'workspace:id': 'hash123' }, null, 2), 'utf-8', ); }); @@ -287,14 +287,14 @@ describe('PolicyIntegrityManager', () => { mockFs.mkdir.mockResolvedValue(undefined); mockFs.writeFile.mockResolvedValue(undefined); - await integrityManager.acceptIntegrity('project', 'id', 'hash123'); + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); expect(mockFs.writeFile).toHaveBeenCalledWith( '/mock/storage/policy_integrity.json', JSON.stringify( { 'other:id': 'otherhash', - 'project:id': 'hash123', + 'workspace:id': 'hash123', }, null, 2, diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 2bd18554d02..e7129208c8c 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -5,11 +5,11 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - User 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 > Project > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # # Settings-based and dynamic rules (all in user tier 3.x): diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 41f6b2205b2..1688d5108c8 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -5,11 +5,11 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - User 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 > Project > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # # Settings-based and dynamic rules (all in user tier 3.x): diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 8f1e3d33e13..47cd9c98ae6 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -5,11 +5,11 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - User 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 > Project > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # # Settings-based and dynamic rules (all in user tier 3.x): diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 174099d7eeb..332334db7ca 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -5,11 +5,11 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - User 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 > Project > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # # Settings-based and dynamic rules (all in user tier 3.x): diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 115c758063c..af3ecc1bdaf 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -234,7 +234,7 @@ modes = ["autoEdit"] expect(result2.rules).toHaveLength(1); expect(result2.rules[0].toolName).toBe('tier2-tool'); expect(result2.rules[0].modes).toEqual(['autoEdit']); - expect(result2.rules[0].source).toBe('Project: tier2.toml'); + expect(result2.rules[0].source).toBe('Workspace: tier2.toml'); const getPolicyTier3 = (_dir: string) => 3; // Tier 3 const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index 53b0c6b3fdc..b1fa63cf83c 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' | 'project' | 'admin'; + tier: 'default' | 'user' | 'workspace' | 'admin'; ruleIndex?: number; errorType: PolicyFileErrorType; message: string; @@ -172,9 +172,9 @@ export async function readPolicyFiles( /** * Converts a tier number to a human-readable tier name. */ -function getTierName(tier: number): 'default' | 'user' | 'project' | 'admin' { +function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' { if (tier === 1) return 'default'; - if (tier === 2) return 'project'; + if (tier === 2) return 'workspace'; if (tier === 3) return 'user'; if (tier === 4) return 'admin'; return 'default'; diff --git a/packages/core/src/policy/project-policy.test.ts b/packages/core/src/policy/workspace-policy.test.ts similarity index 86% rename from packages/core/src/policy/project-policy.test.ts rename to packages/core/src/policy/workspace-policy.test.ts index b3592f71e52..fea2ee39db9 100644 --- a/packages/core/src/policy/project-policy.test.ts +++ b/packages/core/src/policy/workspace-policy.test.ts @@ -14,7 +14,7 @@ vi.mock('../utils/security.js', () => ({ isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }), })); -describe('Project-Level Policies', () => { +describe('Workspace-Level Policies', () => { beforeEach(async () => { vi.resetModules(); const { Storage } = await import('../config/storage.js'); @@ -34,8 +34,8 @@ describe('Project-Level Policies', () => { vi.doUnmock('node:fs/promises'); }); - it('should load project policies with correct priority (Tier 2)', async () => { - const projectPoliciesDir = '/mock/project/policies'; + it('should load workspace policies with correct priority (Tier 2)', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; const defaultPoliciesDir = '/mock/default/policies'; // Mock FS @@ -69,10 +69,10 @@ describe('Project-Level Policies', () => { return [ { name: 'user.toml', isFile: () => true, isDirectory: () => false }, ] as unknown as Awaited>; - if (normalizedPath.endsWith('project/policies')) + if (normalizedPath.endsWith('workspace/policies')) return [ { - name: 'project.toml', + name: 'workspace.toml', isFile: () => true, isDirectory: () => false, }, @@ -100,7 +100,7 @@ decision = "deny" priority = 10 `; // Tier 3 -> 3.010 } - if (path.includes('project.toml')) { + if (path.includes('workspace.toml')) { return `[[rule]] toolName = "test_tool" decision = "allow" @@ -132,12 +132,12 @@ priority = 10 const { createPolicyEngineConfig } = await import('./config.js'); - // Test 1: Project vs User (User should win) + // Test 1: Workspace vs User (User should win) const config = await createPolicyEngineConfig( {}, ApprovalMode.DEFAULT, defaultPoliciesDir, - projectPoliciesDir, + workspacePoliciesDir, ); const rules = config.rules?.filter((r) => r.toolName === 'test_tool'); @@ -145,22 +145,22 @@ priority = 10 // Check for all 4 rules const defaultRule = rules?.find((r) => r.priority === 1.01); - const projectRule = rules?.find((r) => r.priority === 2.01); + const workspaceRule = rules?.find((r) => r.priority === 2.01); const userRule = 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(workspaceRule).toBeDefined(); expect(adminRule).toBeDefined(); - // Verify Hierarchy: Admin > User > Project > Default + // Verify Hierarchy: Admin > User > Workspace > Default expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); - expect(userRule!.priority).toBeGreaterThan(projectRule!.priority!); - expect(projectRule!.priority).toBeGreaterThan(defaultRule!.priority!); + expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!); + expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!); }); - it('should ignore project policies if projectPoliciesDir is undefined', async () => { + it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => { const defaultPoliciesDir = '/mock/default/policies'; // Mock FS (simplified) @@ -217,7 +217,7 @@ priority=10`, {}, ApprovalMode.DEFAULT, defaultPoliciesDir, - undefined, // No project dir + undefined, // No workspace dir ); // Should only have default tier rule (1.01) @@ -226,8 +226,8 @@ priority=10`, expect(rules![0].priority).toBe(1.01); }); - it('should load project policies and correctly transform to Tier 2', async () => { - const projectPoliciesDir = '/mock/project/policies'; + it('should load workspace policies and correctly transform to Tier 2', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; // Mock FS const actualFs = @@ -247,10 +247,10 @@ priority=10`, const mockReaddir = vi.fn(async (path: string) => { const normalizedPath = nodePath.normalize(path); - if (normalizedPath.endsWith('project/policies')) + if (normalizedPath.endsWith('workspace/policies')) return [ { - name: 'project.toml', + name: 'workspace.toml', isFile: () => true, isDirectory: () => false, }, @@ -283,12 +283,12 @@ priority=500`, {}, ApprovalMode.DEFAULT, undefined, - projectPoliciesDir, + workspacePoliciesDir, ); const rule = config.rules?.find((r) => r.toolName === 'p_tool'); expect(rule).toBeDefined(); - // Project Tier (2) + 500/1000 = 2.5 + // Workspace Tier (2) + 500/1000 = 2.5 expect(rule?.priority).toBe(2.5); }); }); From 62068623f655c06c03bcf76bcff9162bf70f833c Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Wed, 18 Feb 2026 12:54:47 -0800 Subject: [PATCH 09/14] feat(policy): implement hot-reloading for workspace policies This change eliminates the need for a CLI restart when a user accepts new or changed project-level policies. Workspace rules are now dynamically injected into the active PolicyEngine instance. Key improvements: - Added Config.loadWorkspacePolicies() to handle mid-session rule injection. - Fully encapsulated acceptance and integrity logic within PolicyUpdateDialog. - Integrated centralized keybindings (Command.ESCAPE) for dialog dismissal. - Refactored PolicyIntegrityManager tests to use a real temporary directory instead of filesystem mocks for improved reliability. - Updated copyright headers to 2026 across affected files. - Added UI snapshot tests for the policy update dialog. Addresses review feedback from PR #18682. --- packages/cli/src/test-utils/render.tsx | 2 +- packages/cli/src/ui/AppContainer.tsx | 35 +-- .../cli/src/ui/components/DialogManager.tsx | 12 +- .../ui/components/PolicyUpdateDialog.test.tsx | 115 +++++---- .../src/ui/components/PolicyUpdateDialog.tsx | 60 +++-- .../PolicyUpdateDialog.test.tsx.snap | 14 + .../cli/src/ui/contexts/UIActionsContext.tsx | 5 +- .../cli/src/ui/contexts/UIStateContext.tsx | 1 - packages/core/src/config/config.ts | 28 ++ packages/core/src/policy/config.ts | 2 +- packages/core/src/policy/integrity.test.ts | 239 +++++++----------- packages/core/src/policy/integrity.ts | 2 +- packages/core/src/policy/policy-engine.ts | 2 +- 13 files changed, 248 insertions(+), 269 deletions(-) create mode 100644 packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index f6a2648857a..d84c04d01e1 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -506,7 +506,7 @@ const mockUIActions: UIActions = { vimHandleInput: vi.fn(), handleIdePromptComplete: vi.fn(), handleFolderTrustSelect: vi.fn(), - handlePolicyUpdateSelect: vi.fn(), + setIsPolicyUpdateDialogOpen: vi.fn(), setConstrainHeight: vi.fn(), onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3e2a54c7c85..0675a3d79ff 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -122,7 +122,7 @@ import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE, relaunchApp } from '../utils/processUtils.js'; +import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; @@ -154,7 +154,6 @@ import { } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; -import { PolicyUpdateChoice } from './components/PolicyUpdateDialog.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; @@ -1446,32 +1445,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( !!policyUpdateConfirmationRequest, ); - const [isRestartingPolicyUpdate, setIsRestartingPolicyUpdate] = - useState(false); - - const handlePolicyUpdateSelect = useCallback( - async (choice: PolicyUpdateChoice) => { - if ( - choice === PolicyUpdateChoice.ACCEPT && - policyUpdateConfirmationRequest - ) { - const integrityManager = new PolicyIntegrityManager(); - await integrityManager.acceptIntegrity( - policyUpdateConfirmationRequest.scope, - policyUpdateConfirmationRequest.identifier, - policyUpdateConfirmationRequest.newHash, - ); - setIsRestartingPolicyUpdate(true); - // Give time for the UI to render the restarting message - setTimeout(async () => { - await relaunchApp(); - }, 250); - } else { - setIsPolicyUpdateDialogOpen(false); - } - }, - [policyUpdateConfirmationRequest], - ); const { needsRestart: ideNeedsRestart, @@ -2175,7 +2148,6 @@ Logging in with Google... Restarting Gemini CLI to continue. isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, - isRestartingPolicyUpdate, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2300,7 +2272,6 @@ Logging in with Google... Restarting Gemini CLI to continue. isFolderTrustDialogOpen, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, - isRestartingPolicyUpdate, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2398,7 +2369,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, - handlePolicyUpdateSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, @@ -2483,7 +2454,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, - handlePolicyUpdateSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, handleEscapePromptChange, refreshStatic, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 11119c12b07..9fdd4718a6f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -167,16 +167,12 @@ export const DialogManager = ({ /> ); } - if ( - uiState.isPolicyUpdateDialogOpen && - uiState.policyUpdateConfirmationRequest - ) { + if (uiState.isPolicyUpdateDialogOpen) { return ( uiActions.setIsPolicyUpdateDialogOpen(false)} /> ); } diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx index 51755648865..d54b6106385 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx @@ -4,46 +4,76 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; +import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; import { - PolicyUpdateDialog, - PolicyUpdateChoice, -} from './PolicyUpdateDialog.js'; + type Config, + type PolicyUpdateConfirmationRequest, + PolicyIntegrityManager, +} from '@google/gemini-cli-core'; + +// Mock PolicyIntegrityManager +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (await importOriginal()) as any; + return { + ...original, + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + acceptIntegrity: vi.fn().mockResolvedValue(undefined), + })), + }; +}); describe('PolicyUpdateDialog', () => { + let mockConfig: Config; + let mockRequest: PolicyUpdateConfirmationRequest; + let onClose: () => void; + + beforeEach(() => { + mockConfig = { + loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined), + } as unknown as Config; + + mockRequest = { + scope: 'workspace', + identifier: '/test/workspace/.gemini/policies', + policyDir: '/test/workspace/.gemini/policies', + newHash: 'test-hash', + } as PolicyUpdateConfirmationRequest; + + onClose = vi.fn(); + }); + afterEach(() => { vi.clearAllMocks(); }); - it('renders correctly with default props', () => { - const onSelect = vi.fn(); + it('renders correctly and matches snapshot', () => { const { lastFrame } = renderWithProviders( , ); const output = lastFrame(); + expect(output).toMatchSnapshot(); expect(output).toContain('New or changed workspace policies detected'); - expect(output).toContain('Location: /test/path'); + expect(output).toContain('Location: /test/workspace/.gemini/policies'); expect(output).toContain('Accept and Load'); expect(output).toContain('Ignore'); }); - it('calls onSelect with ACCEPT when accept option is chosen', async () => { - const onSelect = vi.fn(); + it('handles ACCEPT correctly', async () => { const { stdin } = renderWithProviders( , ); @@ -53,18 +83,20 @@ describe('PolicyUpdateDialog', () => { }); await waitFor(() => { - expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.ACCEPT); + expect(PolicyIntegrityManager).toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith( + mockRequest.policyDir, + ); + expect(onClose).toHaveBeenCalled(); }); }); - it('calls onSelect with IGNORE when ignore option is chosen', async () => { - const onSelect = vi.fn(); + it('handles IGNORE correctly', async () => { const { stdin } = renderWithProviders( , ); @@ -77,44 +109,27 @@ describe('PolicyUpdateDialog', () => { }); await waitFor(() => { - expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.IGNORE); + expect(PolicyIntegrityManager).not.toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); }); }); - it('calls onSelect with IGNORE when Escape is pressed', async () => { - const onSelect = vi.fn(); + it('calls onClose when Escape key is pressed', async () => { const { stdin } = renderWithProviders( , ); await act(async () => { - stdin.write('\x1B'); // Escape key + stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default) }); await waitFor(() => { - expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.IGNORE); + expect(onClose).toHaveBeenCalled(); }); }); - - it('displays restarting message when isRestarting is true', () => { - const onSelect = vi.fn(); - const { lastFrame } = renderWithProviders( - , - ); - - const output = lastFrame(); - expect(output).toContain( - 'Gemini CLI is restarting to apply the policy changes...', - ); - }); }); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx index c05044bd9c4..395a2dd3cc5 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -5,11 +5,18 @@ */ import { Box, Text } from 'ink'; +import { useCallback } from 'react'; import type React from 'react'; +import { + type Config, + type PolicyUpdateConfirmationRequest, + PolicyIntegrityManager, +} from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; export enum PolicyUpdateChoice { ACCEPT = 'accept', @@ -17,32 +24,46 @@ export enum PolicyUpdateChoice { } interface PolicyUpdateDialogProps { - onSelect: (choice: PolicyUpdateChoice) => void; - scope: string; - identifier: string; - isRestarting?: boolean; + config: Config; + request: PolicyUpdateConfirmationRequest; + onClose: () => void; } export const PolicyUpdateDialog: React.FC = ({ - onSelect, - scope, - identifier, - isRestarting, + config, + request, + onClose, }) => { + const handleSelect = useCallback( + async (choice: PolicyUpdateChoice) => { + if (choice === PolicyUpdateChoice.ACCEPT) { + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + request.scope, + request.identifier, + request.newHash, + ); + await config.loadWorkspacePolicies(request.policyDir); + } + onClose(); + }, + [config, request, onClose], + ); + useKeypress( (key) => { - if (key.name === 'escape') { - onSelect(PolicyUpdateChoice.IGNORE); + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); return true; } return false; }, - { isActive: !isRestarting }, + { isActive: true }, ); const options: Array> = [ { - label: 'Accept and Load (Requires Restart)', + label: 'Accept and Load', value: PolicyUpdateChoice.ACCEPT, key: 'accept', }, @@ -65,9 +86,9 @@ export const PolicyUpdateDialog: React.FC = ({ > - New or changed {scope} policies detected + New or changed {request.scope} policies detected - Location: {identifier} + Location: {request.identifier} Do you want to accept and load these policies? @@ -75,17 +96,10 @@ export const PolicyUpdateDialog: React.FC = ({ - {isRestarting && ( - - - Gemini CLI is restarting to apply the policy changes... - - - )} ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap new file mode 100644 index 00000000000..a8bd583cb22 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap @@ -0,0 +1,14 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = ` +" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New or changed workspace policies detected │ + │ Location: /test/workspace/.gemini/policies │ + │ Do you want to accept and load these policies? │ + │ │ + │ ● 1. Accept and Load │ + │ 2. Ignore (Use Default Policies) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index afd49f4f4ee..03780c50686 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -8,7 +8,6 @@ import { createContext, useContext } from 'react'; import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; -import { type PolicyUpdateChoice } from '../components/PolicyUpdateDialog.js'; import { type AuthType, type EditorType, @@ -53,7 +52,7 @@ export interface UIActions { vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; handleFolderTrustSelect: (choice: FolderTrustChoice) => void; - handlePolicyUpdateSelect: (choice: PolicyUpdateChoice) => Promise; + setIsPolicyUpdateDialogOpen: (value: boolean) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; refreshStatic: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 82b43d36167..56d4b83c09b 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -115,7 +115,6 @@ export interface UIState { isFolderTrustDialogOpen: boolean; isPolicyUpdateDialogOpen: boolean; policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; - isRestartingPolicyUpdate: boolean; isTrustedFolder: boolean | undefined; constrainHeight: boolean; showErrorDetails: boolean; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6ceab582aa5..ba8afd084b3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -126,6 +126,8 @@ import { import { fetchAdminControls } from '../code_assist/admin/admin_controls.js'; import { isSubpath } from '../utils/paths.js'; import { UserHintService } from './userHintService.js'; +import { WORKSPACE_POLICY_TIER } from '../policy/config.js'; +import { loadPoliciesFromToml } from '../policy/toml-loader.js'; export interface AccessibilitySettings { /** @deprecated Use ui.loadingPhrases instead. */ @@ -1740,6 +1742,32 @@ export class Config { return this.policyUpdateConfirmationRequest; } + /** + * Hot-loads workspace policies from the specified directory into the active policy engine. + * This allows applying newly accepted policies without requiring an application restart. + * + * @param policyDir The directory containing the workspace policy TOML files. + */ + async loadWorkspacePolicies(policyDir: string): Promise { + const { rules, checkers } = await loadPoliciesFromToml( + [policyDir], + () => WORKSPACE_POLICY_TIER, + ); + + for (const rule of rules) { + this.policyEngine.addRule(rule); + } + + for (const checker of checkers) { + this.policyEngine.addChecker(checker); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any + (this as any).policyUpdateConfirmationRequest = undefined; + + debugLogger.debug(`Workspace policies loaded from: ${policyDir}`); + } + setApprovalMode(mode: ApprovalMode): void { if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) { throw new Error( diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index a9414d65b62..413ef81ae7d 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts index a289c513d0b..32ebf560589 100644 --- a/packages/core/src/policy/integrity.test.ts +++ b/packages/core/src/policy/integrity.test.ts @@ -1,73 +1,47 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - afterEach, - beforeEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js'; - -// Mock dependencies -vi.mock('../config/storage.js', () => ({ - Storage: { - getPolicyIntegrityStoragePath: vi - .fn() - .mockReturnValue('/mock/storage/policy_integrity.json'), - }, -})); - -vi.mock('./toml-loader.js', () => ({ - readPolicyFiles: vi.fn(), -})); - -// Mock FS -const mockFs = vi.hoisted(() => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), -})); - -vi.mock('node:fs/promises', () => ({ - default: mockFs, - readFile: mockFs.readFile, - writeFile: mockFs.writeFile, - mkdir: mockFs.mkdir, -})); +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { Storage } from '../config/storage.js'; describe('PolicyIntegrityManager', () => { let integrityManager: PolicyIntegrityManager; - let readPolicyFilesMock: Mock; + let tempDir: string; + let integrityStoragePath: string; beforeEach(async () => { - vi.resetModules(); - const { readPolicyFiles } = await import('./toml-loader.js'); - readPolicyFilesMock = readPolicyFiles as Mock; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + integrityStoragePath = path.join(tempDir, 'policy_integrity.json'); + + vi.spyOn(Storage, 'getPolicyIntegrityStoragePath').mockReturnValue( + integrityStoragePath, + ); + integrityManager = new PolicyIntegrityManager(); }); - afterEach(() => { - vi.clearAllMocks(); + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); }); describe('checkIntegrity', () => { it('should return NEW if no stored hash', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentA' }, - ]); + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); const result = await integrityManager.checkIntegrity( 'workspace', 'id', - '/dir', + policyDir, ); expect(result.status).toBe(IntegrityStatus.NEW); expect(result.hash).toBeDefined(); @@ -76,187 +50,171 @@ describe('PolicyIntegrityManager', () => { }); it('should return MATCH if stored hash matches', async () => { - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentA' }, - ]); - // We can't easily get the expected hash without calling private method or re-implementing logic. - // But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash. - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + // First run to get the hash const resultNew = await integrityManager.checkIntegrity( 'workspace', 'id', - '/dir', + policyDir, ); const currentHash = resultNew.hash; - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - 'workspace:id': currentHash, - }), + // Save the hash to mock storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': currentHash }), ); const result = await integrityManager.checkIntegrity( 'workspace', 'id', - '/dir', + policyDir, ); expect(result.status).toBe(IntegrityStatus.MATCH); expect(result.hash).toBe(currentHash); - expect(result.fileCount).toBe(1); }); it('should return MISMATCH if stored hash differs', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentA' }, - ]); + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + const resultNew = await integrityManager.checkIntegrity( 'workspace', 'id', - '/dir', + policyDir, ); const currentHash = resultNew.hash; - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - 'workspace:id': 'different_hash', - }), + // Save a different hash + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': 'different_hash' }), ); const result = await integrityManager.checkIntegrity( 'workspace', 'id', - '/dir', + policyDir, ); expect(result.status).toBe(IntegrityStatus.MISMATCH); expect(result.hash).toBe(currentHash); - expect(result.fileCount).toBe(1); }); it('should result in different hash if filename changes', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentA' }, - ]); const result1 = await integrityManager.checkIntegrity( 'workspace', 'id', - '/workspace/policies', + policyDir1, ); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/b.toml', content: 'contentA' }, - ]); + const policyDir2 = path.join(tempDir, 'policies2'); + await fs.mkdir(policyDir2); + await fs.writeFile(path.join(policyDir2, 'b.toml'), 'contentA'); + const result2 = await integrityManager.checkIntegrity( 'workspace', 'id', - '/workspace/policies', + policyDir2, ); expect(result1.hash).not.toBe(result2.hash); }); it('should result in different hash if content changes', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentA' }, - ]); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); const result1 = await integrityManager.checkIntegrity( 'workspace', 'id', - '/workspace/policies', + policyDir, ); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentB' }, - ]); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentB'); const result2 = await integrityManager.checkIntegrity( 'workspace', 'id', - '/workspace/policies', + policyDir, ); expect(result1.hash).not.toBe(result2.hash); }); it('should be deterministic (sort order)', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + await fs.writeFile(path.join(policyDir1, 'b.toml'), 'contentB'); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/a.toml', content: 'contentA' }, - { path: '/workspace/policies/b.toml', content: 'contentB' }, - ]); const result1 = await integrityManager.checkIntegrity( 'workspace', 'id', - '/workspace/policies', + policyDir1, ); - readPolicyFilesMock.mockResolvedValue([ - { path: '/workspace/policies/b.toml', content: 'contentB' }, - { path: '/workspace/policies/a.toml', content: 'contentA' }, - ]); + // Re-read with same files but they might be in different order in readdir + // PolicyIntegrityManager should sort them. const result2 = await integrityManager.checkIntegrity( 'workspace', 'id', - '/workspace/policies', + policyDir1, ); expect(result1.hash).toBe(result2.hash); }); it('should handle multiple projects correctly', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + const dirA = path.join(tempDir, 'dirA'); + await fs.mkdir(dirA); + await fs.writeFile(path.join(dirA, 'p.toml'), 'contentA'); + + const dirB = path.join(tempDir, 'dirB'); + await fs.mkdir(dirB); + await fs.writeFile(path.join(dirB, 'p.toml'), 'contentB'); - // First, get hashes for two different projects - readPolicyFilesMock.mockResolvedValue([ - { path: '/dirA/p.toml', content: 'contentA' }, - ]); const { hash: hashA } = await integrityManager.checkIntegrity( 'workspace', 'idA', - '/dirA', + dirA, ); - - readPolicyFilesMock.mockResolvedValue([ - { path: '/dirB/p.toml', content: 'contentB' }, - ]); const { hash: hashB } = await integrityManager.checkIntegrity( 'workspace', 'idB', - '/dirB', + dirB, ); - // Now mock storage with both - mockFs.readFile.mockResolvedValue( + // Save to storage + await fs.writeFile( + integrityStoragePath, JSON.stringify({ 'workspace:idA': hashA, - 'workspace:idB': 'oldHashB', // Different from hashB + 'workspace:idB': 'oldHashB', }), ); // Project A should match - readPolicyFilesMock.mockResolvedValue([ - { path: '/dirA/p.toml', content: 'contentA' }, - ]); const resultA = await integrityManager.checkIntegrity( 'workspace', 'idA', - '/dirA', + dirA, ); expect(resultA.status).toBe(IntegrityStatus.MATCH); expect(resultA.hash).toBe(hashA); // Project B should mismatch - readPolicyFilesMock.mockResolvedValue([ - { path: '/dirB/p.toml', content: 'contentB' }, - ]); const resultB = await integrityManager.checkIntegrity( 'workspace', 'idB', - '/dirB', + dirB, ); expect(resultB.status).toBe(IntegrityStatus.MISMATCH); expect(resultB.hash).toBe(hashB); @@ -265,42 +223,27 @@ describe('PolicyIntegrityManager', () => { describe('acceptIntegrity', () => { it('should save the hash to storage', async () => { - mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // Start empty - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile.mockResolvedValue(undefined); - await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); - expect(mockFs.writeFile).toHaveBeenCalledWith( - '/mock/storage/policy_integrity.json', - JSON.stringify({ 'workspace:id': 'hash123' }, null, 2), - 'utf-8', + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), ); + expect(stored['workspace:id']).toBe('hash123'); }); it('should update existing hash', async () => { - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - 'other:id': 'otherhash', - }), + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'other:id': 'otherhash' }), ); - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile.mockResolvedValue(undefined); await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); - expect(mockFs.writeFile).toHaveBeenCalledWith( - '/mock/storage/policy_integrity.json', - JSON.stringify( - { - 'other:id': 'otherhash', - 'workspace:id': 'hash123', - }, - null, - 2, - ), - 'utf-8', + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), ); + expect(stored['other:id']).toBe('otherhash'); + expect(stored['workspace:id']).toBe('hash123'); }); }); }); diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts index d9661853ae1..77eb49f7e4e 100644 --- a/packages/core/src/policy/integrity.ts +++ b/packages/core/src/policy/integrity.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 3f386edd8f2..25d1983278a 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ From e0c02a92a392b6cb908c30166597d7e2a4dd588e Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Wed, 18 Feb 2026 14:34:58 -0800 Subject: [PATCH 10/14] refactor(policy): consolidate workspacePoliciesDir into PolicySettings Simplified createPolicyEngineConfig signature by moving workspacePoliciesDir into the PolicySettings interface. Updated all core and CLI call sites and tests to align with the consolidated settings structure. --- packages/cli/src/config/config.test.ts | 3 -- packages/cli/src/config/policy.ts | 8 +--- .../src/config/workspace-policy-cli.test.ts | 39 ++++++++++++------- packages/core/src/policy/config.ts | 9 +++-- packages/core/src/policy/types.ts | 2 + .../core/src/policy/workspace-policy.test.ts | 10 ++--- 6 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index ce7ce1d5909..615eff41ca0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3218,7 +3218,6 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), expect.anything(), undefined, - undefined, ); }); @@ -3241,7 +3240,6 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), expect.anything(), undefined, - undefined, ); }); @@ -3263,7 +3261,6 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), expect.anything(), undefined, - undefined, ); }); }); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 5d48271edea..2caa8d71e0e 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -27,14 +27,10 @@ export async function createPolicyEngineConfig( tools: settings.tools, mcpServers: settings.mcpServers, policyPaths: settings.policyPaths, + workspacePoliciesDir, }; - return createCorePolicyEngineConfig( - policySettings, - approvalMode, - undefined, - workspacePoliciesDir, - ); + return createCorePolicyEngineConfig(policySettings, approvalMode); } export function createPolicyUpdater( diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index e6ad5bfc4c1..630c7245b8f 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -81,10 +81,13 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( - expect.anything(), + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), expect.anything(), undefined, - expect.stringContaining(path.join('.gemini', 'policies')), ); }); @@ -100,9 +103,10 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), expect.anything(), - expect.anything(), - undefined, undefined, ); }); @@ -124,9 +128,10 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), expect.anything(), - expect.anything(), - undefined, undefined, ); }); @@ -152,9 +157,10 @@ describe('Workspace-Level Policy CLI Integration', () => { expect.stringContaining('Workspace policies changed or are new'), ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), expect.anything(), - expect.anything(), - undefined, undefined, // Should NOT load policies ); }); @@ -181,10 +187,13 @@ describe('Workspace-Level Policy CLI Integration', () => { 'new-hash', ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( - expect.anything(), + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), expect.anything(), undefined, - expect.stringContaining(path.join('.gemini', 'policies')), ); }); @@ -220,10 +229,11 @@ describe('Workspace-Level Policy CLI Integration', () => { // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. // The UI will handle the confirmation and reload/update. expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( - expect.anything(), + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), expect.anything(), undefined, - undefined, ); }); @@ -254,9 +264,10 @@ describe('Workspace-Level Policy CLI Integration', () => { }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), expect.anything(), - expect.anything(), - undefined, undefined, ); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 413ef81ae7d..4db5533c805 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -167,12 +167,11 @@ export async function createPolicyEngineConfig( settings: PolicySettings, approvalMode: ApprovalMode, defaultPoliciesDir?: string, - workspacePoliciesDir?: string, ): Promise { const policyDirs = getPolicyDirectories( defaultPoliciesDir, settings.policyPaths, - workspacePoliciesDir, + settings.workspacePoliciesDir, ); const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs); @@ -186,7 +185,11 @@ export async function createPolicyEngineConfig( checkers: tomlCheckers, errors, } = await loadPoliciesFromToml(securePolicyDirs, (p) => { - const tier = getPolicyTier(p, defaultPoliciesDir, workspacePoliciesDir); + const tier = getPolicyTier( + p, + defaultPoliciesDir, + settings.workspacePoliciesDir, + ); // If it's a user-provided path that isn't already categorized as ADMIN, // treat it as USER tier. diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 2e672fff262..17ae6003a17 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -272,7 +272,9 @@ export interface PolicySettings { allowed?: string[]; }; mcpServers?: Record; + // User provided policies that will replace the USER level policies in ~/.gemini/policies policyPaths?: string[]; + workspacePoliciesDir?: string; } export interface CheckResult { diff --git a/packages/core/src/policy/workspace-policy.test.ts b/packages/core/src/policy/workspace-policy.test.ts index fea2ee39db9..999dae6f0dd 100644 --- a/packages/core/src/policy/workspace-policy.test.ts +++ b/packages/core/src/policy/workspace-policy.test.ts @@ -134,10 +134,9 @@ priority = 10 // Test 1: Workspace vs User (User should win) const config = await createPolicyEngineConfig( - {}, + { workspacePoliciesDir }, ApprovalMode.DEFAULT, defaultPoliciesDir, - workspacePoliciesDir, ); const rules = config.rules?.filter((r) => r.toolName === 'test_tool'); @@ -214,10 +213,9 @@ priority=10`, const { createPolicyEngineConfig } = await import('./config.js'); const config = await createPolicyEngineConfig( - {}, + { workspacePoliciesDir: undefined }, ApprovalMode.DEFAULT, defaultPoliciesDir, - undefined, // No workspace dir ); // Should only have default tier rule (1.01) @@ -280,10 +278,8 @@ priority=500`, const { createPolicyEngineConfig } = await import('./config.js'); const config = await createPolicyEngineConfig( - {}, + { workspacePoliciesDir }, ApprovalMode.DEFAULT, - undefined, - workspacePoliciesDir, ); const rule = config.rules?.find((r) => r.toolName === 'p_tool'); From bbb97fccdb5c5635886c71f6a1d2992c4a5a9712 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Wed, 18 Feb 2026 15:15:36 -0800 Subject: [PATCH 11/14] refactor(cli): abstract workspace policy resolution logic Centralized the workspace policy discovery and integrity verification logic into a new 'resolveWorkspacePolicyState' helper in the policy module. This significantly simplifies 'loadCliConfig' in config.ts, reducing its imperative bloat and removing low-level core dependencies from the main configuration flow. - Moved workspace integrity check and directory discovery to policy.ts - Refactored loadCliConfig to use the new declarative resolver - Added comprehensive unit tests for the resolver using real temp dirs - Cleaned up redundant function arguments in core and CLI calls - Verified project integrity with 'npm run preflight' --- packages/cli/src/config/config.ts | 64 ++----- packages/cli/src/config/policy.test.ts | 158 ++++++++++++++++++ packages/cli/src/config/policy.ts | 75 +++++++++ .../src/config/workspace-policy-cli.test.ts | 7 - 4 files changed, 243 insertions(+), 61 deletions(-) create mode 100644 packages/cli/src/config/policy.test.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e021a0048a8..8a8977d3880 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -40,13 +40,9 @@ import { Config, applyAdminAllowlist, getAdminBlockedMcpServersMessage, - Storage, type HookDefinition, type HookEventName, type OutputFormat, - PolicyIntegrityManager, - IntegrityStatus, - type PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type Settings, @@ -60,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { createPolicyEngineConfig } from './policy.js'; +import { + createPolicyEngineConfig, + resolveWorkspacePolicyState, +} from './policy.js'; import { ExtensionManager } from './extension-manager.js'; import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; @@ -702,56 +701,13 @@ export async function loadCliConfig( policyPaths: argv.policy, }; - let workspacePoliciesDir: string | undefined; - let policyUpdateConfirmationRequest: - | PolicyUpdateConfirmationRequest - | undefined; - - if (trustedFolder) { - const potentialWorkspacePoliciesDir = new Storage( + const { workspacePoliciesDir, policyUpdateConfirmationRequest } = + await resolveWorkspacePolicyState({ cwd, - ).getWorkspacePoliciesDir(); - const integrityManager = new PolicyIntegrityManager(); - const integrityResult = await integrityManager.checkIntegrity( - 'workspace', - cwd, - potentialWorkspacePoliciesDir, - ); - - if (integrityResult.status === IntegrityStatus.MATCH) { - workspacePoliciesDir = potentialWorkspacePoliciesDir; - } else if ( - integrityResult.status === IntegrityStatus.NEW && - integrityResult.fileCount === 0 - ) { - // No workspace policies found - workspacePoliciesDir = undefined; - } else { - // Policies changed or are new - if (argv.acceptChangedPolicies) { - debugLogger.warn( - 'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', - ); - await integrityManager.acceptIntegrity( - 'workspace', - cwd, - integrityResult.hash, - ); - workspacePoliciesDir = potentialWorkspacePoliciesDir; - } else if (interactive) { - policyUpdateConfirmationRequest = { - scope: 'workspace', - identifier: cwd, - policyDir: potentialWorkspacePoliciesDir, - newHash: integrityResult.hash, - }; - } else { - debugLogger.warn( - 'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', - ); - } - } - } + trustedFolder, + interactive, + acceptChangedPolicies: argv.acceptChangedPolicies ?? false, + }); const policyEngineConfig = await createPolicyEngineConfig( effectiveSettings, diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts new file mode 100644 index 00000000000..6b7e1021bc0 --- /dev/null +++ b/packages/cli/src/config/policy.test.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { resolveWorkspacePolicyState } from './policy.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +// Mock debugLogger to avoid noise in test output +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +describe('resolveWorkspacePolicyState', () => { + let tempDir: string; + let workspaceDir: string; + let policiesDir: string; + + beforeEach(() => { + // Create a temporary directory for the test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + // Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + workspaceDir = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspaceDir); + policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('should return empty state if folder is not trusted', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: false, + interactive: true, + acceptChangedPolicies: false, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return policy directory if integrity matches', async () => { + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // First call to establish integrity (auto-accept) + await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + acceptChangedPolicies: true, + }); + + // Second call should match + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + acceptChangedPolicies: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return undefined if integrity is NEW but fileCount is 0', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + acceptChangedPolicies: false, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should auto-accept changed policies if acceptChangedPolicies is true', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + acceptChangedPolicies: true, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Auto-accepting'), + ); + }); + + it('should return confirmation request if changed in interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + acceptChangedPolicies: false, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + }); + + it('should warn and return undefined if changed in non-interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + acceptChangedPolicies: false, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Loading default policies only'), + ); + }); +}); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 2caa8d71e0e..49d906acae8 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -12,6 +12,11 @@ import { type PolicySettings, createPolicyEngineConfig as createCorePolicyEngineConfig, createPolicyUpdater as createCorePolicyUpdater, + PolicyIntegrityManager, + IntegrityStatus, + Storage, + type PolicyUpdateConfirmationRequest, + debugLogger, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; @@ -39,3 +44,73 @@ export function createPolicyUpdater( ) { return createCorePolicyUpdater(policyEngine, messageBus); } + +export interface WorkspacePolicyState { + workspacePoliciesDir?: string; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; +} + +/** + * Resolves the workspace policy state by checking folder trust and policy integrity. + */ +export async function resolveWorkspacePolicyState(options: { + cwd: string; + trustedFolder: boolean; + interactive: boolean; + acceptChangedPolicies: boolean; +}): Promise { + const { cwd, trustedFolder, interactive, acceptChangedPolicies } = options; + + let workspacePoliciesDir: string | undefined; + let policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; + + if (trustedFolder) { + const potentialWorkspacePoliciesDir = new Storage( + cwd, + ).getWorkspacePoliciesDir(); + const integrityManager = new PolicyIntegrityManager(); + const integrityResult = await integrityManager.checkIntegrity( + 'workspace', + cwd, + potentialWorkspacePoliciesDir, + ); + + if (integrityResult.status === IntegrityStatus.MATCH) { + workspacePoliciesDir = potentialWorkspacePoliciesDir; + } else if ( + integrityResult.status === IntegrityStatus.NEW && + integrityResult.fileCount === 0 + ) { + // No workspace policies found + workspacePoliciesDir = undefined; + } else { + // Policies changed or are new + if (acceptChangedPolicies) { + debugLogger.warn( + 'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', + ); + await integrityManager.acceptIntegrity( + 'workspace', + cwd, + integrityResult.hash, + ); + workspacePoliciesDir = potentialWorkspacePoliciesDir; + } else if (interactive) { + policyUpdateConfirmationRequest = { + scope: 'workspace', + identifier: cwd, + policyDir: potentialWorkspacePoliciesDir, + newHash: integrityResult.hash, + }; + } else { + debugLogger.warn( + 'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', + ); + } + } + } + + return { workspacePoliciesDir, policyUpdateConfirmationRequest }; +} diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index 630c7245b8f..d39d48c19c6 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -87,7 +87,6 @@ describe('Workspace-Level Policy CLI Integration', () => { ), }), expect.anything(), - undefined, ); }); @@ -107,7 +106,6 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), - undefined, ); }); @@ -132,7 +130,6 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), - undefined, ); }); @@ -161,7 +158,6 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), - undefined, // Should NOT load policies ); }); @@ -193,7 +189,6 @@ describe('Workspace-Level Policy CLI Integration', () => { ), }), expect.anything(), - undefined, ); }); @@ -233,7 +228,6 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), - undefined, ); }); @@ -268,7 +262,6 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), - undefined, ); }); }); From 5a379342a0e24b5e6fd4cae3af8677433aba7d9a Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Wed, 18 Feb 2026 15:32:17 -0800 Subject: [PATCH 12/14] fix(cli): remove unused PolicyIntegrityManager import in AppContainer --- packages/cli/src/ui/AppContainer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0675a3d79ff..b7945b0e10b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -81,7 +81,6 @@ import { CoreToolCallStatus, generateSteeringAckMessage, buildUserSteeringHintPrompt, - PolicyIntegrityManager, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; From 51c9deb54840fbd985cdebae7cdbb6afbd1660d3 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Wed, 18 Feb 2026 16:03:40 -0800 Subject: [PATCH 13/14] test(cli): update createPolicyEngineConfig mock expectations --- packages/cli/src/config/config.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 615eff41ca0..809b31cd827 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3217,7 +3217,6 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), }), expect.anything(), - undefined, ); }); @@ -3239,7 +3238,6 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), }), expect.anything(), - undefined, ); }); @@ -3260,7 +3258,6 @@ describe('Policy Engine Integration in loadCliConfig', () => { policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'], }), expect.anything(), - undefined, ); }); }); From ed6a20d7067f9ec3ebca8ab547b9575e360631c2 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Thu, 19 Feb 2026 12:44:28 -0800 Subject: [PATCH 14/14] feat(policy): address review feedback and improve project-level policy reliability Addressed PR review feedback by refining policy lifecycle management, improving TypeScript strictness, and streamlining CLI arguments. - Removed redundant '--accept-changed-policies' flag. - Updated non-interactive mode to automatically load changed policies with a warning. - Replaced unsafe 'as NodeJS.ErrnoException' casts with 'isNodeError(e)'. - Implemented safer TOML/JSON parsing with better validation and type guards. - Added 'removeRulesByTier' and 'removeCheckersByTier' to PolicyEngine for clean policy reloading. - Integrated a concurrency guard in PolicyUpdateDialog to prevent interleaved calls. - Updated CLI and SDK tests to align with new logic and improved type safety. --- packages/cli/src/config/config.ts | 7 -- packages/cli/src/config/policy.test.ts | 49 ++++------- packages/cli/src/config/policy.ts | 47 +++++----- .../src/config/workspace-policy-cli.test.ts | 30 +------ packages/cli/src/gemini.test.tsx | 1 - .../ui/components/PolicyUpdateDialog.test.tsx | 16 ++-- .../src/ui/components/PolicyUpdateDialog.tsx | 31 ++++--- .../PolicyUpdateDialog.test.tsx.snap | 21 ++--- packages/core/src/config/config.ts | 9 +- packages/core/src/policy/config.ts | 13 ++- packages/core/src/policy/integrity.ts | 19 +++-- .../core/src/policy/policy-engine.test.ts | 85 +++++++++++++++++++ packages/core/src/policy/policy-engine.ts | 18 ++++ packages/core/src/policy/toml-loader.test.ts | 15 ++++ packages/core/src/policy/toml-loader.ts | 20 ++--- packages/core/src/policy/types.ts | 6 ++ 16 files changed, 243 insertions(+), 144 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8a8977d3880..27b251139c9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -97,7 +97,6 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; - acceptChangedPolicies: boolean | undefined; } export async function parseArguments( @@ -289,11 +288,6 @@ export async function parseArguments( .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', - }) - .option('accept-changed-policies', { - type: 'boolean', - description: - 'Automatically accept changed workspace policies (use with caution).', }), ) // Register MCP subcommands @@ -706,7 +700,6 @@ export async function loadCliConfig( cwd, trustedFolder, interactive, - acceptChangedPolicies: argv.acceptChangedPolicies ?? false, }); const policyEngineConfig = await createPolicyEngineConfig( diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 6b7e1021bc0..a0e687388d0 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { resolveWorkspacePolicyState } from './policy.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { writeToStderr } from '@google/gemini-cli-core'; // Mock debugLogger to avoid noise in test output vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -22,6 +22,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { error: vi.fn(), debug: vi.fn(), }, + writeToStderr: vi.fn(), }; }); @@ -54,7 +55,6 @@ describe('resolveWorkspacePolicyState', () => { cwd: workspaceDir, trustedFolder: false, interactive: true, - acceptChangedPolicies: false, }); expect(result).toEqual({ @@ -68,20 +68,28 @@ describe('resolveWorkspacePolicyState', () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); - // First call to establish integrity (auto-accept) - await resolveWorkspacePolicyState({ + // First call to establish integrity (interactive accept) + const firstResult = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, - acceptChangedPolicies: true, }); + expect(firstResult.policyUpdateConfirmationRequest).toBeDefined(); + + // Establish integrity manually as if accepted + const { PolicyIntegrityManager } = await import('@google/gemini-cli-core'); + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + 'workspace', + workspaceDir, + firstResult.policyUpdateConfirmationRequest!.newHash, + ); // Second call should match const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, - acceptChangedPolicies: false, }); expect(result.workspacePoliciesDir).toBe(policiesDir); @@ -93,31 +101,12 @@ describe('resolveWorkspacePolicyState', () => { cwd: workspaceDir, trustedFolder: true, interactive: true, - acceptChangedPolicies: false, }); expect(result.workspacePoliciesDir).toBeUndefined(); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should auto-accept changed policies if acceptChangedPolicies is true', async () => { - fs.mkdirSync(policiesDir, { recursive: true }); - fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); - - const result = await resolveWorkspacePolicyState({ - cwd: workspaceDir, - trustedFolder: true, - interactive: true, - acceptChangedPolicies: true, - }); - - expect(result.workspacePoliciesDir).toBe(policiesDir); - expect(result.policyUpdateConfirmationRequest).toBeUndefined(); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Auto-accepting'), - ); - }); - it('should return confirmation request if changed in interactive mode', async () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); @@ -126,7 +115,6 @@ describe('resolveWorkspacePolicyState', () => { cwd: workspaceDir, trustedFolder: true, interactive: true, - acceptChangedPolicies: false, }); expect(result.workspacePoliciesDir).toBeUndefined(); @@ -138,7 +126,7 @@ describe('resolveWorkspacePolicyState', () => { }); }); - it('should warn and return undefined if changed in non-interactive mode', async () => { + it('should warn and auto-accept if changed in non-interactive mode', async () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); @@ -146,13 +134,12 @@ describe('resolveWorkspacePolicyState', () => { cwd: workspaceDir, trustedFolder: true, interactive: false, - acceptChangedPolicies: false, }); - expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.workspacePoliciesDir).toBe(policiesDir); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Loading default policies only'), + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), ); }); }); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 49d906acae8..e689094f947 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -16,7 +16,7 @@ import { IntegrityStatus, Storage, type PolicyUpdateConfirmationRequest, - debugLogger, + writeToStderr, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; @@ -57,9 +57,8 @@ export async function resolveWorkspacePolicyState(options: { cwd: string; trustedFolder: boolean; interactive: boolean; - acceptChangedPolicies: boolean; }): Promise { - const { cwd, trustedFolder, interactive, acceptChangedPolicies } = options; + const { cwd, trustedFolder, interactive } = options; let workspacePoliciesDir: string | undefined; let policyUpdateConfirmationRequest: @@ -85,30 +84,26 @@ export async function resolveWorkspacePolicyState(options: { ) { // No workspace policies found workspacePoliciesDir = undefined; + } else if (interactive) { + // Policies changed or are new, and we are in interactive mode + policyUpdateConfirmationRequest = { + scope: 'workspace', + identifier: cwd, + policyDir: potentialWorkspacePoliciesDir, + newHash: integrityResult.hash, + }; } else { - // Policies changed or are new - if (acceptChangedPolicies) { - debugLogger.warn( - 'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', - ); - await integrityManager.acceptIntegrity( - 'workspace', - cwd, - integrityResult.hash, - ); - workspacePoliciesDir = potentialWorkspacePoliciesDir; - } else if (interactive) { - policyUpdateConfirmationRequest = { - scope: 'workspace', - identifier: cwd, - policyDir: potentialWorkspacePoliciesDir, - newHash: integrityResult.hash, - }; - } else { - debugLogger.warn( - 'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', - ); - } + // Non-interactive mode: warn and automatically accept/load + await integrityManager.acceptIntegrity( + 'workspace', + cwd, + integrityResult.hash, + ); + workspacePoliciesDir = potentialWorkspacePoliciesDir; + // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n', + ); } } diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index d39d48c19c6..98cbe05bcec 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -10,7 +10,6 @@ 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'; -import { debugLogger } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('./trustedFolders.js', () => ({ @@ -133,7 +132,7 @@ describe('Workspace-Level Policy CLI Integration', () => { ); }); - it('should warn and NOT pass workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -150,33 +149,6 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Workspace policies changed or are new'), - ); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( - expect.objectContaining({ - workspacePoliciesDir: undefined, - }), - expect.anything(), - ); - }); - - it('should accept policies if --accept-changed-policies is passed', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue({ - isTrusted: true, - source: 'file', - }); - mockCheckIntegrity.mockResolvedValue({ - status: 'mismatch', - hash: 'new-hash', - fileCount: 1, - }); - - const settings = createTestMergedSettings(); - const argv = { acceptChangedPolicies: true } as unknown as CliArgs; - - await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); - expect(mockAcceptIntegrity).toHaveBeenCalledWith( 'workspace', MOCK_CWD, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 16f349f801e..976d832abd6 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -496,7 +496,6 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, - acceptChangedPolicies: undefined, }); await act(async () => { diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx index d54b6106385..bab59d83ce2 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx @@ -15,14 +15,19 @@ import { PolicyIntegrityManager, } from '@google/gemini-cli-core'; +const { mockAcceptIntegrity } = vi.hoisted(() => ({ + mockAcceptIntegrity: vi.fn(), +})); + // Mock PolicyIntegrityManager vi.mock('@google/gemini-cli-core', async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (await importOriginal()) as any; + const original = + await importOriginal(); return { ...original, PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ - acceptIntegrity: vi.fn().mockResolvedValue(undefined), + acceptIntegrity: mockAcceptIntegrity, + checkIntegrity: vi.fn(), })), }; }); @@ -51,8 +56,8 @@ describe('PolicyUpdateDialog', () => { vi.clearAllMocks(); }); - it('renders correctly and matches snapshot', () => { - const { lastFrame } = renderWithProviders( + it('renders correctly and matches snapshot', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( { />, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); expect(output).toContain('New or changed workspace policies detected'); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx index 395a2dd3cc5..e6ed75c4dbd 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -5,7 +5,7 @@ */ import { Box, Text } from 'ink'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import type React from 'react'; import { type Config, @@ -34,18 +34,29 @@ export const PolicyUpdateDialog: React.FC = ({ request, onClose, }) => { + const isProcessing = useRef(false); + const handleSelect = useCallback( async (choice: PolicyUpdateChoice) => { - if (choice === PolicyUpdateChoice.ACCEPT) { - const integrityManager = new PolicyIntegrityManager(); - await integrityManager.acceptIntegrity( - request.scope, - request.identifier, - request.newHash, - ); - await config.loadWorkspacePolicies(request.policyDir); + if (isProcessing.current) { + return; + } + + isProcessing.current = true; + try { + if (choice === PolicyUpdateChoice.ACCEPT) { + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + request.scope, + request.identifier, + request.newHash, + ); + await config.loadWorkspacePolicies(request.policyDir); + } + onClose(); + } finally { + isProcessing.current = false; } - onClose(); }, [config, request, onClose], ); diff --git a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap index a8bd583cb22..5f5b3c9c270 100644 --- a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap @@ -1,14 +1,15 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = ` -" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ │ - │ New or changed workspace policies detected │ - │ Location: /test/workspace/.gemini/policies │ - │ Do you want to accept and load these policies? │ - │ │ - │ ● 1. Accept and Load │ - │ 2. Ignore (Use Default Policies) │ - │ │ - ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New or changed workspace policies detected │ + │ Location: /test/workspace/.gemini/policies │ + │ Do you want to accept and load these policies? │ + │ │ + │ ● 1. Accept and Load │ + │ 2. Ignore (Use Default Policies) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ba8afd084b3..fa32fd4d5fd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -647,7 +647,7 @@ export class Config { private readonly useWriteTodos: boolean; private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; - private readonly policyUpdateConfirmationRequest: + private policyUpdateConfirmationRequest: | PolicyUpdateConfirmationRequest | undefined; private readonly outputSettings: OutputSettings; @@ -1754,6 +1754,10 @@ export class Config { () => WORKSPACE_POLICY_TIER, ); + // Clear existing workspace policies to prevent duplicates/stale rules + this.policyEngine.removeRulesByTier(WORKSPACE_POLICY_TIER); + this.policyEngine.removeCheckersByTier(WORKSPACE_POLICY_TIER); + for (const rule of rules) { this.policyEngine.addRule(rule); } @@ -1762,8 +1766,7 @@ export class Config { this.policyEngine.addChecker(checker); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any - (this as any).policyUpdateConfirmationRequest = undefined; + this.policyUpdateConfirmationRequest = undefined; debugLogger.debug(`Workspace policies loaded from: ${policyDir}`); } diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 4db5533c805..50fbc0ef2a4 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -29,6 +29,7 @@ import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { isNodeError } from '../utils/errors.js'; import { isDirectorySecure } from '../utils/security.js'; @@ -444,10 +445,16 @@ export function createPolicyUpdater( let existingData: { rule?: TomlRule[] } = {}; try { const fileContent = await fs.readFile(policyFile, 'utf-8'); - existingData = toml.parse(fileContent) as { rule?: TomlRule[] }; + const parsed = toml.parse(fileContent); + if ( + typeof parsed === 'object' && + parsed !== null && + (!('rule' in parsed) || Array.isArray(parsed['rule'])) + ) { + existingData = parsed as { rule?: TomlRule[] }; + } } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!isNodeError(error) || error.code !== 'ENOENT') { debugLogger.warn( `Failed to parse ${policyFile}, overwriting with new policy.`, error, diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts index 77eb49f7e4e..e8716ed4381 100644 --- a/packages/core/src/policy/integrity.ts +++ b/packages/core/src/policy/integrity.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { Storage } from '../config/storage.js'; import { readPolicyFiles } from './toml-loader.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; export enum IntegrityStatus { MATCH = 'MATCH', @@ -120,15 +121,19 @@ export class PolicyIntegrityManager { const storagePath = Storage.getPolicyIntegrityStoragePath(); try { const content = await fs.readFile(storagePath, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(content) as StoredIntegrityData; - } catch (error) { + const parsed: unknown = JSON.parse(content); if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as Record)['code'] === 'ENOENT' + typeof parsed === 'object' && + parsed !== null && + Object.values(parsed).every((v) => typeof v === 'string') ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return parsed as StoredIntegrityData; + } + debugLogger.warn('Invalid policy integrity data format'); + return {}; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { return {}; } debugLogger.error('Failed to load policy integrity data', error); diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 693ae3a4b2a..11e8333f477 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2373,4 +2373,89 @@ describe('PolicyEngine', () => { ); }); }); + + describe('removeRulesByTier', () => { + it('should remove rules matching a specific tier', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 1.1, + }); + engine.addRule({ + toolName: 'rule2', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 2.1, + }); + engine.addRule({ + toolName: 'rule4', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule5', decision: PolicyDecision.ALLOW }); // priority undefined -> 0 + + expect(engine.getRules()).toHaveLength(5); + + engine.removeRulesByTier(1); + + const rules = engine.getRules(); + expect(rules).toHaveLength(3); + expect(rules.some((r) => r.toolName === 'rule1')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule2')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule3')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule4')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule5')).toBe(true); + }); + + it('should handle removing tier 0 rules (including undefined priority)', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule2', decision: PolicyDecision.ALLOW }); // defaults to 0 + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + + expect(engine.getRules()).toHaveLength(3); + + engine.removeRulesByTier(0); + + const rules = engine.getRules(); + expect(rules).toHaveLength(1); + expect(rules[0].toolName).toBe('rule3'); + }); + }); + + describe('removeCheckersByTier', () => { + it('should remove checkers matching a specific tier', () => { + engine.addChecker({ + checker: { type: 'external', name: 'c1' }, + priority: 1.1, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c2' }, + priority: 1.9, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c3' }, + priority: 2.5, + }); + + expect(engine.getCheckers()).toHaveLength(3); + + engine.removeCheckersByTier(1); + + const checkers = engine.getCheckers(); + expect(checkers).toHaveLength(1); + expect(checkers[0].priority).toBe(2.5); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 25d1983278a..353cdae9c14 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -475,6 +475,24 @@ export class PolicyEngine { this.checkers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } + /** + * Remove rules matching a specific tier (priority band). + */ + removeRulesByTier(tier: number): void { + this.rules = this.rules.filter( + (rule) => Math.floor(rule.priority ?? 0) !== tier, + ); + } + + /** + * Remove checkers matching a specific tier (priority band). + */ + removeCheckersByTier(tier: number): void { + this.checkers = this.checkers.filter( + (checker) => Math.floor(checker.priority ?? 0) !== tier, + ); + } + /** * Remove rules for a specific tool. * If source is provided, only rules matching that source are removed. diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index af3ecc1bdaf..e706b16bf77 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -363,6 +363,21 @@ priority = -1 expect(result.errors[0].fileName).toBe('invalid.toml'); expect(result.errors[0].errorType).toBe('schema_validation'); }); + + it('should transform safety checker priorities based on tier', async () => { + const result = await runLoadPoliciesFromToml(` +[[safety_checker]] +toolName = "write_file" +priority = 100 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +`); + + expect(result.checkers).toHaveLength(1); + expect(result.checkers[0].priority).toBe(1.1); // tier 1 + 100/1000 + expect(result.checkers[0].source).toBe('Default: test.toml'); + }); }); describe('Negative Tests', () => { diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index b1fa63cf83c..7be3fe27dc9 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -17,6 +17,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import toml from '@iarna/toml'; import { z, type ZodError } from 'zod'; +import { isNodeError } from '../utils/errors.js'; /** * Schema for a single policy rule in the TOML file (before transformation). @@ -152,12 +153,10 @@ export async function readPolicyFiles( filesToLoad = [path.basename(policyPath)]; } } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; - if (error.code === 'ENOENT') { + if (isNodeError(e) && e.code === 'ENOENT') { return []; } - throw error; + throw e; } const results: PolicyFile[] = []; @@ -279,15 +278,13 @@ export async function loadPoliciesFromToml( try { policyFiles = await readPolicyFiles(p); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; errors.push({ filePath: p, fileName: path.basename(p), tier: tierName, errorType: 'file_read', message: `Failed to read policy path`, - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); continue; } @@ -466,10 +463,11 @@ export async function loadPoliciesFromToml( const safetyCheckerRule: SafetyCheckerRule = { toolName: effectiveToolName, - priority: checker.priority, + priority: transformPriority(checker.priority, tier), // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion checker: checker.checker as SafetyCheckerConfig, modes: checker.modes, + source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`, }; if (argsPattern) { @@ -513,17 +511,15 @@ export async function loadPoliciesFromToml( checkers.push(...parsedCheckers); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; // Catch-all for unexpected errors - if (error.code !== 'ENOENT') { + if (!isNodeError(e) || e.code !== 'ENOENT') { errors.push({ filePath, fileName: file, tier: tierName, errorType: 'file_read', message: 'Failed to read policy file', - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); } } diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 17ae6003a17..e8aa0e6dd13 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -182,6 +182,12 @@ export interface SafetyCheckerRule { * If undefined or empty, it applies to all modes. */ modes?: ApprovalMode[]; + + /** + * Source of the rule. + * e.g. "my-policies.toml", "Workspace: project.toml", etc. + */ + source?: string; } export interface HookExecutionContext {