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 {