Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions docs/reference/policy-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +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. |
| User | 2 | Custom policies defined by the user. |
| Admin | 3 | 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:
Expand All @@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula:

This system guarantees that:

- Admin policies always override User and Default policies.
- User policies always 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: 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 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`.

### Approval modes

Expand Down Expand Up @@ -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` |
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |

#### System-wide policies (Admin)

Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,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';
Expand Down Expand Up @@ -692,9 +695,17 @@ export async function loadCliConfig(
policyPaths: argv.policy,
};

const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
await resolveWorkspacePolicyState({
cwd,
trustedFolder,
interactive,
});

const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
workspacePoliciesDir,
);
policyEngineConfig.nonInteractive = !interactive;

Expand Down Expand Up @@ -758,6 +769,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,
Expand Down
24 changes: 12 additions & 12 deletions packages/cli/src/config/policy-engine.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions packages/cli/src/config/policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* @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 { writeToStderr } 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<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
writeToStderr: 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,
});

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 (interactive accept)
const firstResult = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: 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,
});

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,
});

expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});

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,
});

expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
});

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 = []');

const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: false,
});

expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(writeToStderr).toHaveBeenCalledWith(
expect.stringContaining('Automatically accepting and loading'),
);
});
});
Loading
Loading