feat(policy): implement project-level policy support

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

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

This hierarchy ensures that project-specific rules override user defaults but are still
subject to system-wide admin enforcement.
This commit is contained in:
Abhijit Balaji
2026-02-09 13:42:02 -08:00
parent 261788cf91
commit 322de4309d
8 changed files with 392 additions and 21 deletions

View File

@@ -3198,6 +3198,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -3219,6 +3221,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});

View File

@@ -40,6 +40,7 @@ import {
Config,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
Storage,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -692,9 +693,15 @@ export async function loadCliConfig(
policyPaths: argv.policy,
};
let projectPoliciesDir: string | undefined;
if (trustedFolder) {
projectPoliciesDir = new Storage(cwd).getProjectPoliciesDir();
}
const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
projectPoliciesDir,
);
policyEngineConfig.nonInteractive = !interactive;

View File

@@ -18,6 +18,7 @@ import { type Settings } from './settings.js';
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
projectPoliciesDir?: string,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -28,7 +29,12 @@ export async function createPolicyEngineConfig(
policyPaths: settings.policyPaths,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
return createCorePolicyEngineConfig(
policySettings,
approvalMode,
undefined,
projectPoliciesDir,
);
}
export function createPolicyUpdater(

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'node:path';
import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual<typeof ServerConfig>(
'@google/gemini-cli-core',
);
return {
...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
createPolicyEngineConfig: vi.fn().mockResolvedValue({
rules: [],
checkers: [],
}),
getVersion: vi.fn().mockResolvedValue('test-version'),
};
});
describe('Project-Level Policy CLI Integration', () => {
const MOCK_CWD = process.cwd();
beforeEach(() => {
vi.clearAllMocks();
});
it('should have getProjectPoliciesDir on Storage class', () => {
const storage = new ServerConfig.Storage(MOCK_CWD);
expect(storage.getProjectPoliciesDir).toBeDefined();
expect(typeof storage.getProjectPoliciesDir).toBe('function');
});
it('should pass projectPoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
// The wrapper createPolicyEngineConfig in policy.ts calls createCorePolicyEngineConfig
// We check if the core one was called with 4 arguments, the 4th being the project dir
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
undefined, // defaultPoliciesDir
expect.stringContaining(path.join('.gemini', 'policies')),
);
});
it('should NOT pass projectPoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
// The 4th argument (projectPoliciesDir) should be undefined
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
undefined,
undefined,
);
});
});