diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index d6beff1bf6..57f149a3d4 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -44,6 +44,9 @@ import {
type HookDefinition,
type HookEventName,
type OutputFormat,
+ PolicyIntegrityManager,
+ IntegrityStatus,
+ type PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -95,6 +98,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
+ acceptChangedPolicies: boolean | undefined;
}
export async function parseArguments(
@@ -286,6 +290,11 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
+ })
+ .option('accept-changed-policies', {
+ type: 'boolean',
+ description:
+ 'Automatically accept changed project policies (use with caution).',
}),
)
// Register MCP subcommands
@@ -694,8 +703,54 @@ export async function loadCliConfig(
};
let projectPoliciesDir: string | undefined;
+ let policyUpdateConfirmationRequest:
+ | PolicyUpdateConfirmationRequest
+ | undefined;
+
if (trustedFolder) {
- projectPoliciesDir = new Storage(cwd).getProjectPoliciesDir();
+ const potentialProjectPoliciesDir = new Storage(
+ cwd,
+ ).getProjectPoliciesDir();
+ const integrityManager = new PolicyIntegrityManager();
+ const integrityResult = await integrityManager.checkIntegrity(
+ 'project',
+ cwd,
+ potentialProjectPoliciesDir,
+ );
+
+ if (integrityResult.status === IntegrityStatus.MATCH) {
+ projectPoliciesDir = potentialProjectPoliciesDir;
+ } else if (
+ integrityResult.status === IntegrityStatus.NEW &&
+ integrityResult.fileCount === 0
+ ) {
+ // No project policies found
+ projectPoliciesDir = undefined;
+ } else {
+ // Policies changed or are new
+ if (argv.acceptChangedPolicies) {
+ debugLogger.warn(
+ 'WARNING: Project policies changed or are new. Auto-accepting due to --accept-changed-policies flag.',
+ );
+ await integrityManager.acceptIntegrity(
+ 'project',
+ cwd,
+ integrityResult.hash,
+ );
+ projectPoliciesDir = potentialProjectPoliciesDir;
+ } else if (interactive) {
+ policyUpdateConfirmationRequest = {
+ scope: 'project',
+ identifier: cwd,
+ policyDir: potentialProjectPoliciesDir,
+ newHash: integrityResult.hash,
+ };
+ } else {
+ debugLogger.warn(
+ 'WARNING: Project policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.',
+ );
+ }
+ }
}
const policyEngineConfig = await createPolicyEngineConfig(
@@ -765,6 +820,7 @@ export async function loadCliConfig(
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
policyEngineConfig,
+ policyUpdateConfirmationRequest,
excludeTools,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 976d832abd..16f349f801 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
+ acceptChangedPolicies: undefined,
});
await act(async () => {
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index f043fade8d..07ebf9246f 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -199,6 +199,7 @@ const mockUIActions: UIActions = {
vimHandleInput: vi.fn(),
handleIdePromptComplete: vi.fn(),
handleFolderTrustSelect: vi.fn(),
+ handlePolicyUpdateSelect: vi.fn(),
setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(),
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index a3b460555b..70c7277f06 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -81,6 +81,7 @@ import {
CoreToolCallStatus,
generateSteeringAckMessage,
buildUserSteeringHintPrompt,
+ PolicyIntegrityManager,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -153,6 +154,7 @@ import {
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
+import { PolicyUpdateChoice } from './components/PolicyUpdateDialog.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
@@ -1438,6 +1440,35 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
+
+ const policyUpdateConfirmationRequest =
+ config.getPolicyUpdateConfirmationRequest();
+ const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
+ !!policyUpdateConfirmationRequest,
+ );
+ const [isRestartingPolicyUpdate, setIsRestartingPolicyUpdate] =
+ useState(false);
+
+ const handlePolicyUpdateSelect = useCallback(
+ async (choice: PolicyUpdateChoice) => {
+ if (
+ choice === PolicyUpdateChoice.ACCEPT &&
+ policyUpdateConfirmationRequest
+ ) {
+ const integrityManager = new PolicyIntegrityManager();
+ await integrityManager.acceptIntegrity(
+ policyUpdateConfirmationRequest.scope,
+ policyUpdateConfirmationRequest.identifier,
+ policyUpdateConfirmationRequest.newHash,
+ );
+ setIsRestartingPolicyUpdate(true);
+ } else {
+ setIsPolicyUpdateDialogOpen(false);
+ }
+ },
+ [policyUpdateConfirmationRequest],
+ );
+
const {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
@@ -1908,6 +1939,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
(shouldShowRetentionWarning && retentionCheckComplete) ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
+ isPolicyUpdateDialogOpen ||
adminSettingsChanged ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
@@ -2135,6 +2167,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
+ isPolicyUpdateDialogOpen,
+ policyUpdateConfirmationRequest,
+ isRestartingPolicyUpdate,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2257,6 +2292,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
+ isPolicyUpdateDialogOpen,
+ policyUpdateConfirmationRequest,
+ isRestartingPolicyUpdate,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2354,6 +2392,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
+ handlePolicyUpdateSelect,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
refreshStatic,
@@ -2438,6 +2477,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
+ handlePolicyUpdateSelect,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index b28f5de218..11119c12b0 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
+import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -166,6 +167,19 @@ export const DialogManager = ({
/>
);
}
+ if (
+ uiState.isPolicyUpdateDialogOpen &&
+ uiState.policyUpdateConfirmationRequest
+ ) {
+ return (
+
+ );
+ }
if (uiState.loopDetectionConfirmationRequest) {
return (
void;
+ scope: string;
+ identifier: string;
+ isRestarting?: boolean;
+}
+
+export const PolicyUpdateDialog: React.FC = ({
+ onSelect,
+ scope,
+ identifier,
+ isRestarting,
+}) => {
+ const [exiting, setExiting] = useState(false);
+
+ useEffect(() => {
+ let timer: ReturnType;
+ if (isRestarting) {
+ timer = setTimeout(async () => {
+ await relaunchApp();
+ }, 250);
+ }
+ return () => {
+ if (timer) clearTimeout(timer);
+ };
+ }, [isRestarting]);
+
+ const handleExit = useCallback(() => {
+ setExiting(true);
+ // Give time for the UI to render the exiting message
+ setTimeout(async () => {
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
+ }, 100);
+ }, []);
+
+ useKeypress(
+ (key) => {
+ if (key.name === 'escape') {
+ handleExit();
+ return true;
+ }
+ return false;
+ },
+ { isActive: !isRestarting },
+ );
+
+ const options: Array> = [
+ {
+ label: 'Accept and Load (Requires Restart)',
+ value: PolicyUpdateChoice.ACCEPT,
+ key: 'accept',
+ },
+ {
+ label: 'Ignore (Use Default Policies)',
+ value: PolicyUpdateChoice.IGNORE,
+ key: 'ignore',
+ },
+ ];
+
+ return (
+
+
+
+
+ New or changed {scope} policies detected
+
+ Location: {identifier}
+
+ Do you want to accept and load these policies?
+
+
+
+
+
+ {isRestarting && (
+
+
+ Gemini CLI is restarting to apply the policy changes...
+
+
+ )}
+ {exiting && (
+
+
+ A selection must be made to continue. Exiting since escape was
+ pressed.
+
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index af8706cfb1..afd49f4f4e 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -8,6 +8,7 @@ import { createContext, useContext } from 'react';
import { type Key } from '../hooks/useKeypress.js';
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
+import { type PolicyUpdateChoice } from '../components/PolicyUpdateDialog.js';
import {
type AuthType,
type EditorType,
@@ -52,6 +53,7 @@ export interface UIActions {
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
+ handlePolicyUpdateSelect: (choice: PolicyUpdateChoice) => Promise;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 2df7473b0c..82b43d3616 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -27,6 +27,7 @@ import type {
FallbackIntent,
ValidationIntent,
AgentDefinition,
+ PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink';
@@ -112,6 +113,9 @@ export interface UIState {
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
+ isPolicyUpdateDialogOpen: boolean;
+ policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
+ isRestartingPolicyUpdate: boolean;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index ad2b0a1a1b..cca6c60afb 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -374,6 +374,13 @@ export interface McpEnablementCallbacks {
isFileEnabled: (serverId: string) => Promise;
}
+export interface PolicyUpdateConfirmationRequest {
+ scope: string;
+ identifier: string;
+ policyDir: string;
+ newHash: string;
+}
+
export interface ConfigParameters {
sessionId: string;
clientVersion?: string;
@@ -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(
diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts
index b090509c36..7071806377 100644
--- a/packages/core/src/config/storage.ts
+++ b/packages/core/src/config/storage.ts
@@ -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';
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 95b8d41c29..7b21d63f71 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -17,6 +17,7 @@ export * from './policy/types.js';
export * from './policy/policy-engine.js';
export * from './policy/toml-loader.js';
export * from './policy/config.js';
+export * from './policy/integrity.js';
export * from './confirmation-bus/types.js';
export * from './confirmation-bus/message-bus.js';
diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts
new file mode 100644
index 0000000000..c345914fed
--- /dev/null
+++ b/packages/core/src/policy/integrity.test.ts
@@ -0,0 +1,306 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ afterEach,
+ beforeEach,
+ type Mock,
+} from 'vitest';
+import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js';
+
+// Mock dependencies
+vi.mock('../config/storage.js', () => ({
+ Storage: {
+ getPolicyIntegrityStoragePath: vi
+ .fn()
+ .mockReturnValue('/mock/storage/policy_integrity.json'),
+ },
+}));
+
+vi.mock('./toml-loader.js', () => ({
+ readPolicyFiles: vi.fn(),
+}));
+
+// Mock FS
+const mockFs = vi.hoisted(() => ({
+ readFile: vi.fn(),
+ writeFile: vi.fn(),
+ mkdir: vi.fn(),
+}));
+
+vi.mock('node:fs/promises', () => ({
+ default: mockFs,
+ readFile: mockFs.readFile,
+ writeFile: mockFs.writeFile,
+ mkdir: mockFs.mkdir,
+}));
+
+describe('PolicyIntegrityManager', () => {
+ let integrityManager: PolicyIntegrityManager;
+ let readPolicyFilesMock: Mock;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ const { readPolicyFiles } = await import('./toml-loader.js');
+ readPolicyFilesMock = readPolicyFiles as Mock;
+ integrityManager = new PolicyIntegrityManager();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('checkIntegrity', () => {
+ it('should return NEW if no stored hash', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ ]);
+
+ const result = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/dir',
+ );
+ expect(result.status).toBe(IntegrityStatus.NEW);
+ expect(result.hash).toBeDefined();
+ expect(result.hash).toHaveLength(64);
+ expect(result.fileCount).toBe(1);
+ });
+
+ it('should return MATCH if stored hash matches', async () => {
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ ]);
+ // We can't easily get the expected hash without calling private method or re-implementing logic.
+ // But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash.
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
+ const resultNew = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/dir',
+ );
+ const currentHash = resultNew.hash;
+
+ mockFs.readFile.mockResolvedValue(
+ JSON.stringify({
+ 'project:id': currentHash,
+ }),
+ );
+
+ const result = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/dir',
+ );
+ expect(result.status).toBe(IntegrityStatus.MATCH);
+ expect(result.hash).toBe(currentHash);
+ expect(result.fileCount).toBe(1);
+ });
+
+ it('should return MISMATCH if stored hash differs', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ ]);
+ const resultNew = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/dir',
+ );
+ const currentHash = resultNew.hash;
+
+ mockFs.readFile.mockResolvedValue(
+ JSON.stringify({
+ 'project:id': 'different_hash',
+ }),
+ );
+
+ const result = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/dir',
+ );
+ expect(result.status).toBe(IntegrityStatus.MISMATCH);
+ expect(result.hash).toBe(currentHash);
+ expect(result.fileCount).toBe(1);
+ });
+
+ it('should result in different hash if filename changes', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ ]);
+ const result1 = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/project/policies',
+ );
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/b.toml', content: 'contentA' },
+ ]);
+ const result2 = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/project/policies',
+ );
+
+ expect(result1.hash).not.toBe(result2.hash);
+ });
+
+ it('should result in different hash if content changes', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ ]);
+ const result1 = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/project/policies',
+ );
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentB' },
+ ]);
+ const result2 = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/project/policies',
+ );
+
+ expect(result1.hash).not.toBe(result2.hash);
+ });
+
+ it('should be deterministic (sort order)', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ { path: '/project/policies/b.toml', content: 'contentB' },
+ ]);
+ const result1 = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/project/policies',
+ );
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/project/policies/b.toml', content: 'contentB' },
+ { path: '/project/policies/a.toml', content: 'contentA' },
+ ]);
+ const result2 = await integrityManager.checkIntegrity(
+ 'project',
+ 'id',
+ '/project/policies',
+ );
+
+ expect(result1.hash).toBe(result2.hash);
+ });
+
+ it('should handle multiple projects correctly', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
+
+ // First, get hashes for two different projects
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/dirA/p.toml', content: 'contentA' },
+ ]);
+ const { hash: hashA } = await integrityManager.checkIntegrity(
+ 'project',
+ 'idA',
+ '/dirA',
+ );
+
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/dirB/p.toml', content: 'contentB' },
+ ]);
+ const { hash: hashB } = await integrityManager.checkIntegrity(
+ 'project',
+ 'idB',
+ '/dirB',
+ );
+
+ // Now mock storage with both
+ mockFs.readFile.mockResolvedValue(
+ JSON.stringify({
+ 'project:idA': hashA,
+ 'project:idB': 'oldHashB', // Different from hashB
+ }),
+ );
+
+ // Project A should match
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/dirA/p.toml', content: 'contentA' },
+ ]);
+ const resultA = await integrityManager.checkIntegrity(
+ 'project',
+ 'idA',
+ '/dirA',
+ );
+ expect(resultA.status).toBe(IntegrityStatus.MATCH);
+ expect(resultA.hash).toBe(hashA);
+
+ // Project B should mismatch
+ readPolicyFilesMock.mockResolvedValue([
+ { path: '/dirB/p.toml', content: 'contentB' },
+ ]);
+ const resultB = await integrityManager.checkIntegrity(
+ 'project',
+ 'idB',
+ '/dirB',
+ );
+ expect(resultB.status).toBe(IntegrityStatus.MISMATCH);
+ expect(resultB.hash).toBe(hashB);
+ });
+ });
+
+ describe('acceptIntegrity', () => {
+ it('should save the hash to storage', async () => {
+ mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // Start empty
+ mockFs.mkdir.mockResolvedValue(undefined);
+ mockFs.writeFile.mockResolvedValue(undefined);
+
+ await integrityManager.acceptIntegrity('project', 'id', 'hash123');
+
+ expect(mockFs.writeFile).toHaveBeenCalledWith(
+ '/mock/storage/policy_integrity.json',
+ JSON.stringify({ 'project:id': 'hash123' }, null, 2),
+ 'utf-8',
+ );
+ });
+
+ it('should update existing hash', async () => {
+ mockFs.readFile.mockResolvedValue(
+ JSON.stringify({
+ 'other:id': 'otherhash',
+ }),
+ );
+ mockFs.mkdir.mockResolvedValue(undefined);
+ mockFs.writeFile.mockResolvedValue(undefined);
+
+ await integrityManager.acceptIntegrity('project', 'id', 'hash123');
+
+ expect(mockFs.writeFile).toHaveBeenCalledWith(
+ '/mock/storage/policy_integrity.json',
+ JSON.stringify(
+ {
+ 'other:id': 'otherhash',
+ 'project:id': 'hash123',
+ },
+ null,
+ 2,
+ ),
+ 'utf-8',
+ );
+ });
+ });
+});
diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts
new file mode 100644
index 0000000000..d9661853ae
--- /dev/null
+++ b/packages/core/src/policy/integrity.ts
@@ -0,0 +1,149 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import { Storage } from '../config/storage.js';
+import { readPolicyFiles } from './toml-loader.js';
+import { debugLogger } from '../utils/debugLogger.js';
+
+export enum IntegrityStatus {
+ MATCH = 'MATCH',
+ MISMATCH = 'MISMATCH',
+ NEW = 'NEW',
+}
+
+export interface IntegrityResult {
+ status: IntegrityStatus;
+ hash: string;
+ fileCount: number;
+}
+
+interface StoredIntegrityData {
+ [key: string]: string; // key = scope:identifier, value = hash
+}
+
+export class PolicyIntegrityManager {
+ /**
+ * Checks the integrity of policies in a given directory against the stored hash.
+ *
+ * @param scope The scope of the policy (e.g., 'project', 'user').
+ * @param identifier A unique identifier for the policy scope (e.g., project path).
+ * @param policyDir The directory containing the policy files.
+ * @returns IntegrityResult indicating if the current policies match the stored hash.
+ */
+ async checkIntegrity(
+ scope: string,
+ identifier: string,
+ policyDir: string,
+ ): Promise {
+ const { hash: currentHash, fileCount } =
+ await PolicyIntegrityManager.calculateIntegrityHash(policyDir);
+ const storedData = await this.loadIntegrityData();
+ const key = this.getIntegrityKey(scope, identifier);
+ const storedHash = storedData[key];
+
+ if (!storedHash) {
+ return { status: IntegrityStatus.NEW, hash: currentHash, fileCount };
+ }
+
+ if (storedHash === currentHash) {
+ return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount };
+ }
+
+ return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount };
+ }
+
+ /**
+ * Accepts and persists the current integrity hash for a given policy scope.
+ *
+ * @param scope The scope of the policy.
+ * @param identifier A unique identifier for the policy scope (e.g., project path).
+ * @param hash The hash to persist.
+ */
+ async acceptIntegrity(
+ scope: string,
+ identifier: string,
+ hash: string,
+ ): Promise {
+ const storedData = await this.loadIntegrityData();
+ const key = this.getIntegrityKey(scope, identifier);
+ storedData[key] = hash;
+ await this.saveIntegrityData(storedData);
+ }
+
+ /**
+ * Calculates a SHA-256 hash of all policy files in the directory.
+ * The hash includes the relative file path and content to detect renames and modifications.
+ *
+ * @param policyDir The directory containing the policy files.
+ * @returns The calculated hash and file count
+ */
+ private static async calculateIntegrityHash(
+ policyDir: string,
+ ): Promise<{ hash: string; fileCount: number }> {
+ try {
+ const files = await readPolicyFiles(policyDir);
+
+ // Sort files by path to ensure deterministic hashing
+ files.sort((a, b) => a.path.localeCompare(b.path));
+
+ const hash = crypto.createHash('sha256');
+
+ for (const file of files) {
+ const relativePath = path.relative(policyDir, file.path);
+ // Include relative path and content in the hash
+ hash.update(relativePath);
+ hash.update('\0'); // Separator
+ hash.update(file.content);
+ hash.update('\0'); // Separator
+ }
+
+ return { hash: hash.digest('hex'), fileCount: files.length };
+ } catch (error) {
+ debugLogger.error('Failed to calculate policy integrity hash', error);
+ // Return a unique hash (random) to force a mismatch if calculation fails?
+ // Or throw? Throwing is better so we don't accidentally accept/deny corrupted state.
+ throw error;
+ }
+ }
+
+ private getIntegrityKey(scope: string, identifier: string): string {
+ return `${scope}:${identifier}`;
+ }
+
+ private async loadIntegrityData(): Promise {
+ const storagePath = Storage.getPolicyIntegrityStoragePath();
+ try {
+ const content = await fs.readFile(storagePath, 'utf-8');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return JSON.parse(content) as StoredIntegrityData;
+ } catch (error) {
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ (error as Record)['code'] === 'ENOENT'
+ ) {
+ return {};
+ }
+ debugLogger.error('Failed to load policy integrity data', error);
+ return {};
+ }
+ }
+
+ private async saveIntegrityData(data: StoredIntegrityData): Promise {
+ const storagePath = Storage.getPolicyIntegrityStoragePath();
+ try {
+ await fs.mkdir(path.dirname(storagePath), { recursive: true });
+ await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8');
+ } catch (error) {
+ debugLogger.error('Failed to save policy integrity data', error);
+ throw error;
+ }
+ }
+}
diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts
index b23128a990..53b0c6b3fd 100644
--- a/packages/core/src/policy/toml-loader.ts
+++ b/packages/core/src/policy/toml-loader.ts
@@ -122,6 +122,53 @@ export interface PolicyLoadResult {
errors: PolicyFileError[];
}
+export interface PolicyFile {
+ path: string;
+ content: string;
+}
+
+/**
+ * Reads policy files from a directory or a single file.
+ *
+ * @param policyPath Path to a directory or a .toml file.
+ * @returns Array of PolicyFile objects.
+ */
+export async function readPolicyFiles(
+ policyPath: string,
+): Promise {
+ let filesToLoad: string[] = [];
+ let baseDir = '';
+
+ try {
+ const stats = await fs.stat(policyPath);
+ if (stats.isDirectory()) {
+ baseDir = policyPath;
+ const dirEntries = await fs.readdir(policyPath, { withFileTypes: true });
+ filesToLoad = dirEntries
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
+ .map((entry) => entry.name);
+ } else if (stats.isFile() && policyPath.endsWith('.toml')) {
+ baseDir = path.dirname(policyPath);
+ filesToLoad = [path.basename(policyPath)];
+ }
+ } catch (e) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const error = e as NodeJS.ErrnoException;
+ if (error.code === 'ENOENT') {
+ return [];
+ }
+ throw error;
+ }
+
+ const results: PolicyFile[] = [];
+ for (const file of filesToLoad) {
+ const filePath = path.join(baseDir, file);
+ const content = await fs.readFile(filePath, 'utf-8');
+ results.push({ path: filePath, content });
+ }
+ return results;
+}
+
/**
* Converts a tier number to a human-readable tier name.
*/
@@ -227,30 +274,13 @@ export async function loadPoliciesFromToml(
const tier = getPolicyTier(p);
const tierName = getTierName(tier);
- let filesToLoad: string[] = [];
- let baseDir = '';
+ let policyFiles: PolicyFile[] = [];
try {
- const stats = await fs.stat(p);
- if (stats.isDirectory()) {
- baseDir = p;
- const dirEntries = await fs.readdir(p, { withFileTypes: true });
- filesToLoad = dirEntries
- .filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
- .map((entry) => entry.name);
- } else if (stats.isFile() && p.endsWith('.toml')) {
- baseDir = path.dirname(p);
- filesToLoad = [path.basename(p)];
- }
- // Other file types or non-.toml files are silently ignored
- // for consistency with directory scanning behavior.
+ policyFiles = await readPolicyFiles(p);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as NodeJS.ErrnoException;
- if (error.code === 'ENOENT') {
- // Path doesn't exist, skip it (not an error)
- continue;
- }
errors.push({
filePath: p,
fileName: path.basename(p),
@@ -262,13 +292,10 @@ export async function loadPoliciesFromToml(
continue;
}
- for (const file of filesToLoad) {
- const filePath = path.join(baseDir, file);
+ for (const { path: filePath, content: fileContent } of policyFiles) {
+ const file = path.basename(filePath);
try {
- // Read file
- const fileContent = await fs.readFile(filePath, 'utf-8');
-
// Parse TOML
let parsed: unknown;
try {