mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -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 HookDefinition,
|
||||||
type HookEventName,
|
type HookEventName,
|
||||||
type OutputFormat,
|
type OutputFormat,
|
||||||
|
PolicyIntegrityManager,
|
||||||
|
IntegrityStatus,
|
||||||
|
type PolicyUpdateConfirmationRequest,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
type Settings,
|
type Settings,
|
||||||
@@ -95,6 +98,7 @@ export interface CliArgs {
|
|||||||
rawOutput: boolean | undefined;
|
rawOutput: boolean | undefined;
|
||||||
acceptRawOutputRisk: boolean | undefined;
|
acceptRawOutputRisk: boolean | undefined;
|
||||||
isCommand: boolean | undefined;
|
isCommand: boolean | undefined;
|
||||||
|
acceptChangedPolicies: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseArguments(
|
export async function parseArguments(
|
||||||
@@ -286,6 +290,11 @@ export async function parseArguments(
|
|||||||
.option('accept-raw-output-risk', {
|
.option('accept-raw-output-risk', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Suppress the security warning when using --raw-output.',
|
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
|
// Register MCP subcommands
|
||||||
@@ -694,8 +703,54 @@ export async function loadCliConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let projectPoliciesDir: string | undefined;
|
let projectPoliciesDir: string | undefined;
|
||||||
|
let policyUpdateConfirmationRequest:
|
||||||
|
| PolicyUpdateConfirmationRequest
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (trustedFolder) {
|
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(
|
const policyEngineConfig = await createPolicyEngineConfig(
|
||||||
@@ -765,6 +820,7 @@ export async function loadCliConfig(
|
|||||||
coreTools: settings.tools?.core || undefined,
|
coreTools: settings.tools?.core || undefined,
|
||||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||||
policyEngineConfig,
|
policyEngineConfig,
|
||||||
|
policyUpdateConfirmationRequest,
|
||||||
excludeTools,
|
excludeTools,
|
||||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||||
toolCallCommand: settings.tools?.callCommand,
|
toolCallCommand: settings.tools?.callCommand,
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
rawOutput: undefined,
|
rawOutput: undefined,
|
||||||
acceptRawOutputRisk: undefined,
|
acceptRawOutputRisk: undefined,
|
||||||
isCommand: undefined,
|
isCommand: undefined,
|
||||||
|
acceptChangedPolicies: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ const mockUIActions: UIActions = {
|
|||||||
vimHandleInput: vi.fn(),
|
vimHandleInput: vi.fn(),
|
||||||
handleIdePromptComplete: vi.fn(),
|
handleIdePromptComplete: vi.fn(),
|
||||||
handleFolderTrustSelect: vi.fn(),
|
handleFolderTrustSelect: vi.fn(),
|
||||||
|
handlePolicyUpdateSelect: vi.fn(),
|
||||||
setConstrainHeight: vi.fn(),
|
setConstrainHeight: vi.fn(),
|
||||||
onEscapePromptChange: vi.fn(),
|
onEscapePromptChange: vi.fn(),
|
||||||
refreshStatic: vi.fn(),
|
refreshStatic: vi.fn(),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import {
|
|||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
generateSteeringAckMessage,
|
generateSteeringAckMessage,
|
||||||
buildUserSteeringHintPrompt,
|
buildUserSteeringHintPrompt,
|
||||||
|
PolicyIntegrityManager,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { validateAuthMethod } from '../config/auth.js';
|
import { validateAuthMethod } from '../config/auth.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -153,6 +154,7 @@ import {
|
|||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||||
|
import { PolicyUpdateChoice } from './components/PolicyUpdateDialog.js';
|
||||||
import { isSlashCommand } from './utils/commandUtils.js';
|
import { isSlashCommand } from './utils/commandUtils.js';
|
||||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||||
@@ -1438,6 +1440,35 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
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 {
|
const {
|
||||||
needsRestart: ideNeedsRestart,
|
needsRestart: ideNeedsRestart,
|
||||||
restartReason: ideTrustRestartReason,
|
restartReason: ideTrustRestartReason,
|
||||||
@@ -1908,6 +1939,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
||||||
shouldShowIdePrompt ||
|
shouldShowIdePrompt ||
|
||||||
isFolderTrustDialogOpen ||
|
isFolderTrustDialogOpen ||
|
||||||
|
isPolicyUpdateDialogOpen ||
|
||||||
adminSettingsChanged ||
|
adminSettingsChanged ||
|
||||||
!!commandConfirmationRequest ||
|
!!commandConfirmationRequest ||
|
||||||
!!authConsentRequest ||
|
!!authConsentRequest ||
|
||||||
@@ -2135,6 +2167,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isResuming,
|
isResuming,
|
||||||
shouldShowIdePrompt,
|
shouldShowIdePrompt,
|
||||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||||
|
isPolicyUpdateDialogOpen,
|
||||||
|
policyUpdateConfirmationRequest,
|
||||||
|
isRestartingPolicyUpdate,
|
||||||
isTrustedFolder,
|
isTrustedFolder,
|
||||||
constrainHeight,
|
constrainHeight,
|
||||||
showErrorDetails,
|
showErrorDetails,
|
||||||
@@ -2257,6 +2292,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isResuming,
|
isResuming,
|
||||||
shouldShowIdePrompt,
|
shouldShowIdePrompt,
|
||||||
isFolderTrustDialogOpen,
|
isFolderTrustDialogOpen,
|
||||||
|
isPolicyUpdateDialogOpen,
|
||||||
|
policyUpdateConfirmationRequest,
|
||||||
|
isRestartingPolicyUpdate,
|
||||||
isTrustedFolder,
|
isTrustedFolder,
|
||||||
constrainHeight,
|
constrainHeight,
|
||||||
showErrorDetails,
|
showErrorDetails,
|
||||||
@@ -2354,6 +2392,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
handleIdePromptComplete,
|
handleIdePromptComplete,
|
||||||
handleFolderTrustSelect,
|
handleFolderTrustSelect,
|
||||||
|
handlePolicyUpdateSelect,
|
||||||
setConstrainHeight,
|
setConstrainHeight,
|
||||||
onEscapePromptChange: handleEscapePromptChange,
|
onEscapePromptChange: handleEscapePromptChange,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
@@ -2438,6 +2477,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
handleIdePromptComplete,
|
handleIdePromptComplete,
|
||||||
handleFolderTrustSelect,
|
handleFolderTrustSelect,
|
||||||
|
handlePolicyUpdateSelect,
|
||||||
setConstrainHeight,
|
setConstrainHeight,
|
||||||
handleEscapePromptChange,
|
handleEscapePromptChange,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js';
|
|||||||
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
|
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||||
|
|
||||||
interface DialogManagerProps {
|
interface DialogManagerProps {
|
||||||
addItem: UseHistoryManagerReturn['addItem'];
|
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) {
|
if (uiState.loopDetectionConfirmationRequest) {
|
||||||
return (
|
return (
|
||||||
<LoopDetectionConfirmation
|
<LoopDetectionConfirmation
|
||||||
|
|||||||
@@ -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 Key } from '../hooks/useKeypress.js';
|
||||||
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
||||||
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||||
|
import { type PolicyUpdateChoice } from '../components/PolicyUpdateDialog.js';
|
||||||
import {
|
import {
|
||||||
type AuthType,
|
type AuthType,
|
||||||
type EditorType,
|
type EditorType,
|
||||||
@@ -52,6 +53,7 @@ export interface UIActions {
|
|||||||
vimHandleInput: (key: Key) => boolean;
|
vimHandleInput: (key: Key) => boolean;
|
||||||
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
|
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
|
||||||
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
|
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
|
||||||
|
handlePolicyUpdateSelect: (choice: PolicyUpdateChoice) => Promise<void>;
|
||||||
setConstrainHeight: (value: boolean) => void;
|
setConstrainHeight: (value: boolean) => void;
|
||||||
onEscapePromptChange: (show: boolean) => void;
|
onEscapePromptChange: (show: boolean) => void;
|
||||||
refreshStatic: () => void;
|
refreshStatic: () => void;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
FallbackIntent,
|
FallbackIntent,
|
||||||
ValidationIntent,
|
ValidationIntent,
|
||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
|
PolicyUpdateConfirmationRequest,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { type TransientMessageType } from '../../utils/events.js';
|
import { type TransientMessageType } from '../../utils/events.js';
|
||||||
import type { DOMElement } from 'ink';
|
import type { DOMElement } from 'ink';
|
||||||
@@ -112,6 +113,9 @@ export interface UIState {
|
|||||||
isResuming: boolean;
|
isResuming: boolean;
|
||||||
shouldShowIdePrompt: boolean;
|
shouldShowIdePrompt: boolean;
|
||||||
isFolderTrustDialogOpen: boolean;
|
isFolderTrustDialogOpen: boolean;
|
||||||
|
isPolicyUpdateDialogOpen: boolean;
|
||||||
|
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
|
||||||
|
isRestartingPolicyUpdate: boolean;
|
||||||
isTrustedFolder: boolean | undefined;
|
isTrustedFolder: boolean | undefined;
|
||||||
constrainHeight: boolean;
|
constrainHeight: boolean;
|
||||||
showErrorDetails: boolean;
|
showErrorDetails: boolean;
|
||||||
|
|||||||
@@ -374,6 +374,13 @@ export interface McpEnablementCallbacks {
|
|||||||
isFileEnabled: (serverId: string) => Promise<boolean>;
|
isFileEnabled: (serverId: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PolicyUpdateConfirmationRequest {
|
||||||
|
scope: string;
|
||||||
|
identifier: string;
|
||||||
|
policyDir: string;
|
||||||
|
newHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigParameters {
|
export interface ConfigParameters {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
@@ -454,6 +461,7 @@ export interface ConfigParameters {
|
|||||||
eventEmitter?: EventEmitter;
|
eventEmitter?: EventEmitter;
|
||||||
useWriteTodos?: boolean;
|
useWriteTodos?: boolean;
|
||||||
policyEngineConfig?: PolicyEngineConfig;
|
policyEngineConfig?: PolicyEngineConfig;
|
||||||
|
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
|
||||||
output?: OutputSettings;
|
output?: OutputSettings;
|
||||||
disableModelRouterForAuth?: AuthType[];
|
disableModelRouterForAuth?: AuthType[];
|
||||||
continueOnFailedApiCall?: boolean;
|
continueOnFailedApiCall?: boolean;
|
||||||
@@ -631,6 +639,9 @@ export class Config {
|
|||||||
private readonly useWriteTodos: boolean;
|
private readonly useWriteTodos: boolean;
|
||||||
private readonly messageBus: MessageBus;
|
private readonly messageBus: MessageBus;
|
||||||
private readonly policyEngine: PolicyEngine;
|
private readonly policyEngine: PolicyEngine;
|
||||||
|
private readonly policyUpdateConfirmationRequest:
|
||||||
|
| PolicyUpdateConfirmationRequest
|
||||||
|
| undefined;
|
||||||
private readonly outputSettings: OutputSettings;
|
private readonly outputSettings: OutputSettings;
|
||||||
private readonly continueOnFailedApiCall: boolean;
|
private readonly continueOnFailedApiCall: boolean;
|
||||||
private readonly retryFetchErrors: boolean;
|
private readonly retryFetchErrors: boolean;
|
||||||
@@ -846,6 +857,8 @@ export class Config {
|
|||||||
approvalMode:
|
approvalMode:
|
||||||
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
||||||
});
|
});
|
||||||
|
this.policyUpdateConfirmationRequest =
|
||||||
|
params.policyUpdateConfirmationRequest;
|
||||||
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
||||||
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
|
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
|
||||||
this.skillManager = new SkillManager();
|
this.skillManager = new SkillManager();
|
||||||
@@ -1714,6 +1727,12 @@ export class Config {
|
|||||||
return this.policyEngine.getApprovalMode();
|
return this.policyEngine.getApprovalMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPolicyUpdateConfirmationRequest():
|
||||||
|
| PolicyUpdateConfirmationRequest
|
||||||
|
| undefined {
|
||||||
|
return this.policyUpdateConfirmationRequest;
|
||||||
|
}
|
||||||
|
|
||||||
setApprovalMode(mode: ApprovalMode): void {
|
setApprovalMode(mode: ApprovalMode): void {
|
||||||
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
|
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export class Storage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getPolicyIntegrityStoragePath(): string {
|
||||||
|
return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json');
|
||||||
|
}
|
||||||
|
|
||||||
private static getSystemConfigDir(): string {
|
private static getSystemConfigDir(): string {
|
||||||
if (os.platform() === 'darwin') {
|
if (os.platform() === 'darwin') {
|
||||||
return '/Library/Application Support/GeminiCli';
|
return '/Library/Application Support/GeminiCli';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from './policy/types.js';
|
|||||||
export * from './policy/policy-engine.js';
|
export * from './policy/policy-engine.js';
|
||||||
export * from './policy/toml-loader.js';
|
export * from './policy/toml-loader.js';
|
||||||
export * from './policy/config.js';
|
export * from './policy/config.js';
|
||||||
|
export * from './policy/integrity.js';
|
||||||
export * from './confirmation-bus/types.js';
|
export * from './confirmation-bus/types.js';
|
||||||
export * from './confirmation-bus/message-bus.js';
|
export * from './confirmation-bus/message-bus.js';
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,53 @@ export interface PolicyLoadResult {
|
|||||||
errors: PolicyFileError[];
|
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.
|
* Converts a tier number to a human-readable tier name.
|
||||||
*/
|
*/
|
||||||
@@ -227,30 +274,13 @@ export async function loadPoliciesFromToml(
|
|||||||
const tier = getPolicyTier(p);
|
const tier = getPolicyTier(p);
|
||||||
const tierName = getTierName(tier);
|
const tierName = getTierName(tier);
|
||||||
|
|
||||||
let filesToLoad: string[] = [];
|
let policyFiles: PolicyFile[] = [];
|
||||||
let baseDir = '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(p);
|
policyFiles = await readPolicyFiles(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.
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
const error = e as NodeJS.ErrnoException;
|
const error = e as NodeJS.ErrnoException;
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
// Path doesn't exist, skip it (not an error)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
errors.push({
|
errors.push({
|
||||||
filePath: p,
|
filePath: p,
|
||||||
fileName: path.basename(p),
|
fileName: path.basename(p),
|
||||||
@@ -262,13 +292,10 @@ export async function loadPoliciesFromToml(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of filesToLoad) {
|
for (const { path: filePath, content: fileContent } of policyFiles) {
|
||||||
const filePath = path.join(baseDir, file);
|
const file = path.basename(filePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read file
|
|
||||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
||||||
|
|
||||||
// Parse TOML
|
// Parse TOML
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user