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

View File

@@ -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,

View File

@@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
acceptChangedPolicies: undefined,
});
await act(async () => {

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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 (
<PolicyUpdateDialog
onSelect={uiActions.handlePolicyUpdateSelect}
scope={uiState.policyUpdateConfirmationRequest.scope}
identifier={uiState.policyUpdateConfirmationRequest.policyDir}
isRestarting={uiState.isRestartingPolicyUpdate}
/>
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState, useCallback } from 'react';
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 * as process from 'node:process';
import { relaunchApp } from '../../utils/processUtils.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { ExitCodes } from '@google/gemini-cli-core';
export enum PolicyUpdateChoice {
ACCEPT = 'accept',
IGNORE = 'ignore',
}
interface PolicyUpdateDialogProps {
onSelect: (choice: PolicyUpdateChoice) => void;
scope: string;
identifier: string;
isRestarting?: boolean;
}
export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
onSelect,
scope,
identifier,
isRestarting,
}) => {
const [exiting, setExiting] = useState(false);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
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<RadioSelectItem<PolicyUpdateChoice>> = [
{
label: 'Accept and Load (Requires Restart)',
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 {scope} policies detected
</Text>
<Text color={theme.text.primary}>Location: {identifier}</Text>
<Text color={theme.text.primary}>
Do you want to accept and load these policies?
</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={onSelect}
isFocused={!isRestarting}
/>
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
Gemini CLI is restarting to apply the policy changes...
</Text>
</Box>
)}
{exiting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
A selection must be made to continue. Exiting since escape was
pressed.
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -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<void>;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;

View File

@@ -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;