feat(policy): implement project policy integrity verification

Adds a security mechanism to detect and prompt for confirmation when project-level policies are added or modified. This prevents unauthorized policy changes from being applied silently.

- PolicyIntegrityManager calculates and persists policy directory hashes.
- Config integrates integrity checks during startup.
- PolicyUpdateDialog prompts users in interactive mode.
- --accept-changed-policies flag supports non-interactive workflows.
- toml-loader refactored to expose file reading logic.
This commit is contained in:
Abhijit Balaji
2026-02-13 15:24:54 -08:00
parent 53511d6ed4
commit c73e47bbbe
14 changed files with 776 additions and 25 deletions
+19
View File
@@ -374,6 +374,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;
@@ -454,6 +461,7 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
@@ -631,6 +639,9 @@ export class Config {
private readonly useWriteTodos: boolean;
private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private readonly policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
| undefined;
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
@@ -846,6 +857,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();
@@ -1714,6 +1727,12 @@ export class Config {
return this.policyEngine.getApprovalMode();
}
getPolicyUpdateConfirmationRequest():
| PolicyUpdateConfirmationRequest
| undefined {
return this.policyUpdateConfirmationRequest;
}
setApprovalMode(mode: ApprovalMode): void {
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
throw new Error(
+4
View File
@@ -93,6 +93,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';
+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';
+306
View File
@@ -0,0 +1,306 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
afterEach,
beforeEach,
type Mock,
} from 'vitest';
import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js';
// Mock dependencies
vi.mock('../config/storage.js', () => ({
Storage: {
getPolicyIntegrityStoragePath: vi
.fn()
.mockReturnValue('/mock/storage/policy_integrity.json'),
},
}));
vi.mock('./toml-loader.js', () => ({
readPolicyFiles: vi.fn(),
}));
// Mock FS
const mockFs = vi.hoisted(() => ({
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
default: mockFs,
readFile: mockFs.readFile,
writeFile: mockFs.writeFile,
mkdir: mockFs.mkdir,
}));
describe('PolicyIntegrityManager', () => {
let integrityManager: PolicyIntegrityManager;
let readPolicyFilesMock: Mock;
beforeEach(async () => {
vi.resetModules();
const { readPolicyFiles } = await import('./toml-loader.js');
readPolicyFilesMock = readPolicyFiles as Mock;
integrityManager = new PolicyIntegrityManager();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('checkIntegrity', () => {
it('should return NEW if no stored hash', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
]);
const result = await integrityManager.checkIntegrity(
'project',
'id',
'/dir',
);
expect(result.status).toBe(IntegrityStatus.NEW);
expect(result.hash).toBeDefined();
expect(result.hash).toHaveLength(64);
expect(result.fileCount).toBe(1);
});
it('should return MATCH if stored hash matches', async () => {
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
]);
// We can't easily get the expected hash without calling private method or re-implementing logic.
// But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash.
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const resultNew = await integrityManager.checkIntegrity(
'project',
'id',
'/dir',
);
const currentHash = resultNew.hash;
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'project:id': currentHash,
}),
);
const result = await integrityManager.checkIntegrity(
'project',
'id',
'/dir',
);
expect(result.status).toBe(IntegrityStatus.MATCH);
expect(result.hash).toBe(currentHash);
expect(result.fileCount).toBe(1);
});
it('should return MISMATCH if stored hash differs', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
]);
const resultNew = await integrityManager.checkIntegrity(
'project',
'id',
'/dir',
);
const currentHash = resultNew.hash;
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'project:id': 'different_hash',
}),
);
const result = await integrityManager.checkIntegrity(
'project',
'id',
'/dir',
);
expect(result.status).toBe(IntegrityStatus.MISMATCH);
expect(result.hash).toBe(currentHash);
expect(result.fileCount).toBe(1);
});
it('should result in different hash if filename changes', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
]);
const result1 = await integrityManager.checkIntegrity(
'project',
'id',
'/project/policies',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/b.toml', content: 'contentA' },
]);
const result2 = await integrityManager.checkIntegrity(
'project',
'id',
'/project/policies',
);
expect(result1.hash).not.toBe(result2.hash);
});
it('should result in different hash if content changes', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
]);
const result1 = await integrityManager.checkIntegrity(
'project',
'id',
'/project/policies',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentB' },
]);
const result2 = await integrityManager.checkIntegrity(
'project',
'id',
'/project/policies',
);
expect(result1.hash).not.toBe(result2.hash);
});
it('should be deterministic (sort order)', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/project/policies/b.toml', content: 'contentB' },
]);
const result1 = await integrityManager.checkIntegrity(
'project',
'id',
'/project/policies',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/b.toml', content: 'contentB' },
{ path: '/project/policies/a.toml', content: 'contentA' },
]);
const result2 = await integrityManager.checkIntegrity(
'project',
'id',
'/project/policies',
);
expect(result1.hash).toBe(result2.hash);
});
it('should handle multiple projects correctly', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
// First, get hashes for two different projects
readPolicyFilesMock.mockResolvedValue([
{ path: '/dirA/p.toml', content: 'contentA' },
]);
const { hash: hashA } = await integrityManager.checkIntegrity(
'project',
'idA',
'/dirA',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/dirB/p.toml', content: 'contentB' },
]);
const { hash: hashB } = await integrityManager.checkIntegrity(
'project',
'idB',
'/dirB',
);
// Now mock storage with both
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'project:idA': hashA,
'project:idB': 'oldHashB', // Different from hashB
}),
);
// Project A should match
readPolicyFilesMock.mockResolvedValue([
{ path: '/dirA/p.toml', content: 'contentA' },
]);
const resultA = await integrityManager.checkIntegrity(
'project',
'idA',
'/dirA',
);
expect(resultA.status).toBe(IntegrityStatus.MATCH);
expect(resultA.hash).toBe(hashA);
// Project B should mismatch
readPolicyFilesMock.mockResolvedValue([
{ path: '/dirB/p.toml', content: 'contentB' },
]);
const resultB = await integrityManager.checkIntegrity(
'project',
'idB',
'/dirB',
);
expect(resultB.status).toBe(IntegrityStatus.MISMATCH);
expect(resultB.hash).toBe(hashB);
});
});
describe('acceptIntegrity', () => {
it('should save the hash to storage', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // Start empty
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
await integrityManager.acceptIntegrity('project', 'id', 'hash123');
expect(mockFs.writeFile).toHaveBeenCalledWith(
'/mock/storage/policy_integrity.json',
JSON.stringify({ 'project:id': 'hash123' }, null, 2),
'utf-8',
);
});
it('should update existing hash', async () => {
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'other:id': 'otherhash',
}),
);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
await integrityManager.acceptIntegrity('project', 'id', 'hash123');
expect(mockFs.writeFile).toHaveBeenCalledWith(
'/mock/storage/policy_integrity.json',
JSON.stringify(
{
'other:id': 'otherhash',
'project:id': 'hash123',
},
null,
2,
),
'utf-8',
);
});
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import { readPolicyFiles } from './toml-loader.js';
import { debugLogger } from '../utils/debugLogger.js';
export enum IntegrityStatus {
MATCH = 'MATCH',
MISMATCH = 'MISMATCH',
NEW = 'NEW',
}
export interface IntegrityResult {
status: IntegrityStatus;
hash: string;
fileCount: number;
}
interface StoredIntegrityData {
[key: string]: string; // key = scope:identifier, value = hash
}
export class PolicyIntegrityManager {
/**
* Checks the integrity of policies in a given directory against the stored hash.
*
* @param scope The scope of the policy (e.g., 'project', 'user').
* @param identifier A unique identifier for the policy scope (e.g., project path).
* @param policyDir The directory containing the policy files.
* @returns IntegrityResult indicating if the current policies match the stored hash.
*/
async checkIntegrity(
scope: string,
identifier: string,
policyDir: string,
): Promise<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');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as StoredIntegrityData;
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as Record<string, unknown>)['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;
}
}
}
+51 -24
View File
@@ -122,6 +122,53 @@ export interface PolicyLoadResult {
errors: PolicyFileError[];
}
export interface PolicyFile {
path: string;
content: string;
}
/**
* Reads policy files from a directory or a single file.
*
* @param policyPath Path to a directory or a .toml file.
* @returns Array of PolicyFile objects.
*/
export async function readPolicyFiles(
policyPath: string,
): Promise<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) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
const results: PolicyFile[] = [];
for (const file of filesToLoad) {
const filePath = path.join(baseDir, file);
const content = await fs.readFile(filePath, 'utf-8');
results.push({ path: filePath, content });
}
return results;
}
/**
* Converts a tier number to a human-readable tier name.
*/
@@ -227,30 +274,13 @@ export async function loadPoliciesFromToml(
const tier = getPolicyTier(p);
const tierName = getTierName(tier);
let filesToLoad: string[] = [];
let baseDir = '';
let policyFiles: PolicyFile[] = [];
try {
const stats = await fs.stat(p);
if (stats.isDirectory()) {
baseDir = p;
const dirEntries = await fs.readdir(p, { withFileTypes: true });
filesToLoad = dirEntries
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
.map((entry) => entry.name);
} else if (stats.isFile() && p.endsWith('.toml')) {
baseDir = path.dirname(p);
filesToLoad = [path.basename(p)];
}
// Other file types or non-.toml files are silently ignored
// for consistency with directory scanning behavior.
policyFiles = await readPolicyFiles(p);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
// Path doesn't exist, skip it (not an error)
continue;
}
errors.push({
filePath: p,
fileName: path.basename(p),
@@ -262,13 +292,10 @@ export async function loadPoliciesFromToml(
continue;
}
for (const file of filesToLoad) {
const filePath = path.join(baseDir, file);
for (const { path: filePath, content: fileContent } of policyFiles) {
const file = path.basename(filePath);
try {
// Read file
const fileContent = await fs.readFile(filePath, 'utf-8');
// Parse TOML
let parsed: unknown;
try {