mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
acceptChangedPolicies: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
127
packages/cli/src/ui/components/PolicyUpdateDialog.tsx
Normal file
127
packages/cli/src/ui/components/PolicyUpdateDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user