feat(policy): implement project-level policy support (#18682)

This commit is contained in:
Abhijit Balaji
2026-02-19 16:16:03 -08:00
committed by GitHub
parent d25c469f77
commit d8b24e6983
32 changed files with 1895 additions and 186 deletions
+17 -13
View File
@@ -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:
@@ -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
@@ -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)
+13 -1
View File
@@ -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';
@@ -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;
@@ -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,
@@ -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
+145
View File
@@ -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'),
);
});
});
+72
View File
@@ -12,12 +12,18 @@ import {
type PolicySettings,
createPolicyEngineConfig as createCorePolicyEngineConfig,
createPolicyUpdater as createCorePolicyUpdater,
PolicyIntegrityManager,
IntegrityStatus,
Storage,
type PolicyUpdateConfirmationRequest,
writeToStderr,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
workspacePoliciesDir?: string,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -26,6 +32,7 @@ export async function createPolicyEngineConfig(
tools: settings.tools,
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
workspacePoliciesDir,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
@@ -37,3 +44,68 @@ 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;
}): Promise<WorkspacePolicyState> {
const { cwd, trustedFolder, interactive } = 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 if (interactive) {
// Policies changed or are new, and we are in interactive mode
policyUpdateConfirmationRequest = {
scope: 'workspace',
identifier: cwd,
policyDir: potentialWorkspacePoliciesDir,
newHash: integrityResult.hash,
};
} else {
// 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',
);
}
}
return { workspacePoliciesDir, policyUpdateConfirmationRequest };
}
@@ -0,0 +1,239 @@
/**
* @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(),
}));
const mockCheckIntegrity = vi.fn();
const mockAcceptIntegrity = 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'),
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
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
};
});
describe('Workspace-Level Policy CLI Integration', () => {
const MOCK_CWD = process.cwd();
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 getWorkspacePoliciesDir on Storage class', () => {
const storage = new ServerConfig.Storage(MOCK_CWD);
expect(storage.getWorkspacePoliciesDir).toBeDefined();
expect(typeof storage.getWorkspacePoliciesDir).toBe('function');
});
it('should pass workspacePoliciesDir 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 });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});
it('should NOT pass workspacePoliciesDir 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 });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should NOT pass workspacePoliciesDir 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.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should automatically accept and load workspacePoliciesDir 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(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});
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: 'workspace',
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.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
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: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
});
+1
View File
@@ -506,6 +506,7 @@ const mockUIActions: UIActions = {
vimHandleInput: vi.fn(),
handleIdePromptComplete: vi.fn(),
handleFolderTrustSelect: vi.fn(),
setIsPolicyUpdateDialogOpen: vi.fn(),
setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(),
+14
View File
@@ -1438,6 +1438,13 @@ 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 {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
@@ -1910,6 +1917,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
(shouldShowRetentionWarning && retentionCheckComplete) ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
adminSettingsChanged ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
@@ -2137,6 +2145,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2259,6 +2269,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2356,6 +2368,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
refreshStatic,
@@ -2440,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
@@ -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,15 @@ export const DialogManager = ({
/>
);
}
if (uiState.isPolicyUpdateDialogOpen) {
return (
<PolicyUpdateDialog
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 {
type Config,
type PolicyUpdateConfirmationRequest,
PolicyIntegrityManager,
} from '@google/gemini-cli-core';
const { mockAcceptIntegrity } = vi.hoisted(() => ({
mockAcceptIntegrity: vi.fn(),
}));
// Mock PolicyIntegrityManager
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
acceptIntegrity: mockAcceptIntegrity,
checkIntegrity: vi.fn(),
})),
};
});
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 and matches snapshot', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('New or changed workspace policies detected');
expect(output).toContain('Location: /test/workspace/.gemini/policies');
expect(output).toContain('Accept and Load');
expect(output).toContain('Ignore');
});
it('handles ACCEPT correctly', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
// Accept is the first option, so pressing enter should select it
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(PolicyIntegrityManager).toHaveBeenCalled();
expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith(
mockRequest.policyDir,
);
expect(onClose).toHaveBeenCalled();
});
});
it('handles IGNORE correctly', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
// Move down to Ignore option
await act(async () => {
stdin.write('\x1B[B'); // Down arrow
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(PolicyIntegrityManager).not.toHaveBeenCalled();
expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
it('calls onClose when Escape key is pressed', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
await act(async () => {
stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default)
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useCallback, useRef } 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',
IGNORE = 'ignore',
}
interface PolicyUpdateDialogProps {
config: Config;
request: PolicyUpdateConfirmationRequest;
onClose: () => void;
}
export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
config,
request,
onClose,
}) => {
const isProcessing = useRef(false);
const handleSelect = useCallback(
async (choice: PolicyUpdateChoice) => {
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;
}
},
[config, request, onClose],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose();
return true;
}
return false;
},
{ isActive: true },
);
const options: Array<RadioSelectItem<PolicyUpdateChoice>> = [
{
label: 'Accept and Load',
value: PolicyUpdateChoice.ACCEPT,
key: 'accept',
},
{
label: 'Ignore (Use Default Policies)',
value: PolicyUpdateChoice.IGNORE,
key: 'ignore',
},
];
return (
<Box flexDirection="column" width="100%">
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
marginLeft={1}
marginRight={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
New or changed {request.scope} policies detected
</Text>
<Text color={theme.text.primary}>Location: {request.identifier}</Text>
<Text color={theme.text.primary}>
Do you want to accept and load these policies?
</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={true}
/>
</Box>
</Box>
);
};
@@ -0,0 +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) │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -52,6 +52,7 @@ export interface UIActions {
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
@@ -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,8 @@ export interface UIState {
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
isPolicyUpdateDialogOpen: boolean;
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
+50
View File
@@ -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. */
@@ -379,6 +381,13 @@ export interface McpEnablementCallbacks {
isFileEnabled: (serverId: string) => Promise<boolean>;
}
export interface PolicyUpdateConfirmationRequest {
scope: string;
identifier: string;
policyDir: string;
newHash: string;
}
export interface ConfigParameters {
sessionId: string;
clientVersion?: string;
@@ -459,6 +468,7 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
@@ -637,6 +647,9 @@ export class Config {
private readonly useWriteTodos: boolean;
private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
| undefined;
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
@@ -853,6 +866,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 +1736,41 @@ export class Config {
return this.policyEngine.getApprovalMode();
}
getPolicyUpdateConfirmationRequest():
| PolicyUpdateConfirmationRequest
| undefined {
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<void> {
const { rules, checkers } = await loadPoliciesFromToml(
[policyDir],
() => 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);
}
for (const checker of checkers) {
this.policyEngine.addChecker(checker);
}
this.policyUpdateConfirmationRequest = undefined;
debugLogger.debug(`Workspace policies loaded from: ${policyDir}`);
}
setApprovalMode(mode: ApprovalMode): void {
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
throw new Error(
+8
View File
@@ -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';
@@ -146,6 +150,10 @@ export class Storage {
return path.join(tempDir, identifier);
}
getWorkspacePoliciesDir(): string {
return path.join(this.getGeminiDir(), 'policies');
}
ensureProjectTempDirExists(): void {
fs.mkdirSync(this.getProjectTempDir(), { recursive: true });
}
+1
View File
@@ -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';
+26 -26
View File
@@ -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');
});
+73 -47
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -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';
@@ -38,47 +39,55 @@ 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 WORKSPACE_POLICY_TIER = 2;
export const USER_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 workspacePoliciesDir Optional path to a directory containing workspace policies.
*/
export function getPolicyDirectories(
defaultPoliciesDir?: string,
policyPaths?: string[],
workspacePoliciesDir?: 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 highest priority)
if (policyPaths && policyPaths.length > 0) {
dirs.push(...policyPaths);
} else {
dirs.push(Storage.getUserPoliciesDir());
}
// Admin tier (highest priority)
dirs.push(Storage.getSystemPoliciesDir());
// Workspace Tier (third highest)
if (workspacePoliciesDir) {
dirs.push(workspacePoliciesDir);
}
// Reverse so highest priority (Admin) is first
return dirs.reverse();
// Default tier (lowest priority)
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
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=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,
workspacePoliciesDir?: string,
): number {
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
@@ -99,6 +108,12 @@ export function getPolicyTier(
if (normalizedDir === normalizedUser) {
return USER_POLICY_TIER;
}
if (
workspacePoliciesDir &&
normalizedDir === path.resolve(workspacePoliciesDir)
) {
return WORKSPACE_POLICY_TIER;
}
if (normalizedDir === normalizedAdmin) {
return ADMIN_POLICY_TIER;
}
@@ -157,8 +172,8 @@ export async function createPolicyEngineConfig(
const policyDirs = getPolicyDirectories(
defaultPoliciesDir,
settings.policyPaths,
settings.workspacePoliciesDir,
);
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
const normalizedAdminPoliciesDir = path.resolve(
@@ -171,7 +186,11 @@ export async function createPolicyEngineConfig(
checkers: tomlCheckers,
errors,
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
const tier = getPolicyTier(p, defaultPoliciesDir);
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.
@@ -207,19 +226,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)
// - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.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 > 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 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)
@@ -230,33 +250,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)
@@ -276,7 +296,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)',
});
@@ -288,7 +308,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
priority: 3.3,
source: 'Settings (Tools Allowed)',
});
}
@@ -300,7 +320,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
priority: 3.3,
source: 'Settings (Tools Allowed)',
});
}
@@ -308,7 +328,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,
@@ -319,7 +339,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.ALLOW,
priority: 2.2,
priority: 3.2,
source: 'Settings (MCP Trusted)',
});
}
@@ -327,13 +347,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)',
});
}
@@ -380,10 +400,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)',
});
@@ -405,10 +425,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)',
});
@@ -425,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,
+249
View File
@@ -0,0 +1,249 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js';
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 tempDir: string;
let integrityStoragePath: string;
beforeEach(async () => {
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(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('checkIntegrity', () => {
it('should return NEW if no stored hash', async () => {
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',
policyDir,
);
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 () => {
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',
policyDir,
);
const currentHash = resultNew.hash;
// Save the hash to mock storage
await fs.writeFile(
integrityStoragePath,
JSON.stringify({ 'workspace:id': currentHash }),
);
const result = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result.status).toBe(IntegrityStatus.MATCH);
expect(result.hash).toBe(currentHash);
});
it('should return MISMATCH if stored hash differs', async () => {
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',
policyDir,
);
const currentHash = resultNew.hash;
// Save a different hash
await fs.writeFile(
integrityStoragePath,
JSON.stringify({ 'workspace:id': 'different_hash' }),
);
const result = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result.status).toBe(IntegrityStatus.MISMATCH);
expect(result.hash).toBe(currentHash);
});
it('should result in different hash if filename changes', async () => {
const policyDir1 = path.join(tempDir, 'policies1');
await fs.mkdir(policyDir1);
await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA');
const result1 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir1,
);
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',
policyDir2,
);
expect(result1.hash).not.toBe(result2.hash);
});
it('should result in different hash if content changes', async () => {
const policyDir = path.join(tempDir, 'policies');
await fs.mkdir(policyDir);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA');
const result1 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentB');
const result2 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result1.hash).not.toBe(result2.hash);
});
it('should be deterministic (sort order)', async () => {
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');
const result1 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir1,
);
// 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',
policyDir1,
);
expect(result1.hash).toBe(result2.hash);
});
it('should handle multiple projects correctly', async () => {
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');
const { hash: hashA } = await integrityManager.checkIntegrity(
'workspace',
'idA',
dirA,
);
const { hash: hashB } = await integrityManager.checkIntegrity(
'workspace',
'idB',
dirB,
);
// Save to storage
await fs.writeFile(
integrityStoragePath,
JSON.stringify({
'workspace:idA': hashA,
'workspace:idB': 'oldHashB',
}),
);
// Project A should match
const resultA = await integrityManager.checkIntegrity(
'workspace',
'idA',
dirA,
);
expect(resultA.status).toBe(IntegrityStatus.MATCH);
expect(resultA.hash).toBe(hashA);
// Project B should mismatch
const resultB = await integrityManager.checkIntegrity(
'workspace',
'idB',
dirB,
);
expect(resultB.status).toBe(IntegrityStatus.MISMATCH);
expect(resultB.hash).toBe(hashB);
});
});
describe('acceptIntegrity', () => {
it('should save the hash to storage', async () => {
await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
const stored = JSON.parse(
await fs.readFile(integrityStoragePath, 'utf-8'),
);
expect(stored['workspace:id']).toBe('hash123');
});
it('should update existing hash', async () => {
await fs.writeFile(
integrityStoragePath,
JSON.stringify({ 'other:id': 'otherhash' }),
);
await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
const stored = JSON.parse(
await fs.readFile(integrityStoragePath, 'utf-8'),
);
expect(stored['other:id']).toBe('otherhash');
expect(stored['workspace:id']).toBe('hash123');
});
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2026 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';
import { isNodeError } from '../utils/errors.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<IntegrityResult> {
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<void> {
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<StoredIntegrityData> {
const storagePath = Storage.getPolicyIntegrityStoragePath();
try {
const content = await fs.readFile(storagePath, 'utf-8');
const parsed: unknown = JSON.parse(content);
if (
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);
return {};
}
}
private async saveIntegrityData(data: StoredIntegrityData): Promise<void> {
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;
}
}
}
+1 -1
View File
@@ -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"]|\\\\")`),
);
+11 -10
View File
@@ -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)
# - 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 > 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 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)
@@ -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)
# - 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 > 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 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)
+11 -10
View File
@@ -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)
# - 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 > 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 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)
+11 -10
View File
@@ -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)
# - 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 > 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 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)
@@ -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);
});
});
});
+19 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -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.
+26 -7
View File
@@ -228,14 +228,18 @@ modes = ["autoEdit"]
`,
);
const getPolicyTier = (_dir: string) => 2; // Tier 2
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
const getPolicyTier2 = (_dir: string) => 2; // Tier 2
const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2);
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);
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('Workspace: 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 () => {
@@ -359,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', () => {
+61 -37
View File
@@ -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).
@@ -105,7 +106,7 @@ export type PolicyFileErrorType =
export interface PolicyFileError {
filePath: string;
fileName: string;
tier: 'default' | 'user' | 'admin';
tier: 'default' | 'user' | 'workspace' | 'admin';
ruleIndex?: number;
errorType: PolicyFileErrorType;
message: string;
@@ -122,13 +123,59 @@ 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<PolicyFile[]> {
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) {
if (isNodeError(e) && e.code === 'ENOENT') {
return [];
}
throw e;
}
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.
*/
function getTierName(tier: number): 'default' | 'user' | 'admin' {
function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' {
if (tier === 1) return 'default';
if (tier === 2) return 'user';
if (tier === 3) return 'admin';
if (tier === 2) return 'workspace';
if (tier === 3) return 'user';
if (tier === 4) return 'admin';
return 'default';
}
@@ -211,7 +258,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(
@@ -226,48 +273,26 @@ 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),
tier: tierName,
errorType: 'file_read',
message: `Failed to read policy path`,
details: error.message,
details: isNodeError(e) ? e.message : String(e),
});
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 {
@@ -438,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) {
@@ -485,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),
});
}
}
+8
View File
@@ -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 {
@@ -272,7 +278,9 @@ export interface PolicySettings {
allowed?: string[];
};
mcpServers?: Record<string, { trust?: boolean }>;
// User provided policies that will replace the USER level policies in ~/.gemini/policies
policyPaths?: string[];
workspacePoliciesDir?: string;
}
export interface CheckResult {
@@ -0,0 +1,290 @@
/**
* @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('Workspace-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 workspace policies with correct priority (Tier 2)', async () => {
const workspacePoliciesDir = '/mock/workspace/policies';
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'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<ReturnType<typeof actualFs.stat>>;
}
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.endsWith('default/policies'))
return [
{
name: 'default.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('user/policies'))
return [
{ name: 'user.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('workspace/policies'))
return [
{
name: 'workspace.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('system/policies'))
return [
{ name: 'admin.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
// Mock readFile to return content with distinct priorities/decisions
const mockReadFile = vi.fn(async (path: string) => {
if (path.includes('default.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
priority = 10
`; // Tier 1 -> 1.010
}
if (path.includes('user.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "deny"
priority = 10
`; // Tier 3 -> 3.010
}
if (path.includes('workspace.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
priority = 10
`; // Tier 2 -> 2.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,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
const { createPolicyEngineConfig } = await import('./config.js');
// Test 1: Workspace vs User (User should win)
const config = await createPolicyEngineConfig(
{ workspacePoliciesDir },
ApprovalMode.DEFAULT,
defaultPoliciesDir,
);
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 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(workspaceRule).toBeDefined();
expect(adminRule).toBeDefined();
// Verify Hierarchy: Admin > User > Workspace > Default
expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!);
expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!);
expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!);
});
it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => {
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS (simplified)
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockStat = vi.fn(async (path: string) => {
if (typeof path === 'string' && path.startsWith('/mock/')) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path);
});
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('default/policies'))
return [
{
name: 'default.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
const mockReadFile = vi.fn(
async () => `[[rule]]
toolName="t"
decision="allow"
priority=10`,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const config = await createPolicyEngineConfig(
{ workspacePoliciesDir: undefined },
ApprovalMode.DEFAULT,
defaultPoliciesDir,
);
// 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 workspace policies and correctly transform to Tier 2', async () => {
const workspacePoliciesDir = '/mock/workspace/policies';
// Mock FS
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'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<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path);
});
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('workspace/policies'))
return [
{
name: 'workspace.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
const mockReadFile = vi.fn(
async () => `[[rule]]
toolName="p_tool"
decision="allow"
priority=500`,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const config = await createPolicyEngineConfig(
{ workspacePoliciesDir },
ApprovalMode.DEFAULT,
);
const rule = config.rules?.find((r) => r.toolName === 'p_tool');
expect(rule).toBeDefined();
// Workspace Tier (2) + 500/1000 = 2.5
expect(rule?.priority).toBe(2.5);
});
});