feat(policy): support auto-add to policy by default and scoped persistence

This commit is contained in:
Spencer
2026-03-04 04:01:23 +00:00
parent 0659ad1702
commit 72a9ad3f1b
17 changed files with 706 additions and 235 deletions
+12
View File
@@ -1475,6 +1475,18 @@ const SETTINGS_SCHEMA = {
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
autoAddToPolicyByDefault: {
type: 'boolean',
label: 'Auto-add to Policy by Default',
category: 'Security',
requiresRestart: false,
default: true,
description: oneLine`
When enabled, the "Allow for all future sessions" option becomes the
default choice for low-risk tools in trusted workspaces.
`,
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
@@ -9,12 +9,15 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as fs from 'node:fs/promises';
import {
type Config,
PolicyDecision,
ApprovalMode,
} from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
describe('policiesCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
@@ -26,8 +29,9 @@ describe('policiesCommand', () => {
expect(policiesCommand.name).toBe('policies');
expect(policiesCommand.description).toBe('Manage policies');
expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN);
expect(policiesCommand.subCommands).toHaveLength(1);
expect(policiesCommand.subCommands).toHaveLength(2);
expect(policiesCommand.subCommands![0].name).toBe('list');
expect(policiesCommand.subCommands![1].name).toBe('undo');
});
describe('list subcommand', () => {
@@ -160,4 +164,63 @@ describe('policiesCommand', () => {
expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
});
});
describe('undo subcommand', () => {
it('should show error if config is missing', async () => {
mockContext.services.config = null;
const undoCommand = policiesCommand.subCommands![1];
await undoCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Error: Config not available.',
}),
expect.any(Number),
);
});
it('should show message if no backups found', async () => {
const mockStorage = {
getAutoSavedPolicyPath: vi.fn().mockReturnValue('user.toml'),
getWorkspaceAutoSavedPolicyPath: vi.fn().mockReturnValue('ws.toml'),
};
mockContext.services.config = {
storage: mockStorage,
} as unknown as Config;
vi.mocked(fs.access).mockRejectedValue(new Error('no backup'));
const undoCommand = policiesCommand.subCommands![1];
await undoCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.WARNING,
text: 'No policy backups found to restore.',
}),
expect.any(Number),
);
});
it('should restore backups if found', async () => {
const mockStorage = {
getAutoSavedPolicyPath: vi.fn().mockReturnValue('user.toml'),
getWorkspaceAutoSavedPolicyPath: vi.fn().mockReturnValue('ws.toml'),
};
mockContext.services.config = {
storage: mockStorage,
} as unknown as Config;
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
const undoCommand = policiesCommand.subCommands![1];
await undoCommand.action!(mockContext, '');
expect(fs.copyFile).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Successfully restored'),
}),
expect.any(Number),
);
});
});
});
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
@@ -111,10 +112,66 @@ const listPoliciesCommand: SlashCommand = {
},
};
const undoPoliciesCommand: SlashCommand = {
name: 'undo',
description: 'Undo the last auto-saved policy update',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const { config } = context.services;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Error: Config not available.',
},
Date.now(),
);
return;
}
const storage = config.storage;
const paths = [
storage.getAutoSavedPolicyPath(),
storage.getWorkspaceAutoSavedPolicyPath(),
];
let restoredCount = 0;
for (const p of paths) {
const bak = `${p}.bak`;
try {
await fs.access(bak);
await fs.copyFile(bak, p);
restoredCount++;
} catch {
// No backup or failed to restore
}
}
if (restoredCount > 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Successfully restored ${restoredCount} policy file(s) from backup. Please restart the CLI to apply changes.`,
},
Date.now(),
);
} else {
context.ui.addItem(
{
type: MessageType.WARNING,
text: 'No policy backups found to restore.',
},
Date.now(),
);
}
},
};
export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listPoliciesCommand],
subCommands: [listPoliciesCommand, undoPoliciesCommand],
};
@@ -406,6 +406,41 @@ describe('ToolConfirmationMessage', () => {
expect(lastFrame()).toContain('Allow for all future sessions');
unmount();
});
it('should default to "Allow for all future sessions" when autoAddToPolicyByDefault is true', async () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: createMockSettings({
security: {
enablePermanentToolApproval: true,
autoAddToPolicyByDefault: true,
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
// In Ink, the selected item is usually highlighted with a cursor or different color.
// We can't easily check colors in text output, but we can verify it's NOT the first option
// if we could see the selection indicator.
// Instead, we'll verify the snapshot which should show the selection.
expect(output).toMatchSnapshot();
unmount();
});
});
describe('Modify with external editor option', () => {
@@ -386,255 +386,292 @@ export const ToolConfirmationMessage: React.FC<
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options, securityWarnings } = useMemo<{
question: string;
bodyContent: React.ReactNode;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
securityWarnings: React.ReactNode;
}>(() => {
let bodyContent: React.ReactNode | null = null;
let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
const { question, bodyContent, options, securityWarnings, initialIndex } =
useMemo<{
question: string;
bodyContent: React.ReactNode;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
securityWarnings: React.ReactNode;
initialIndex: number;
}>(() => {
let bodyContent: React.ReactNode | null = null;
let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
if (deceptiveUrlWarningText) {
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
}
let initialIndex = 0;
if (
settings.merged.security.autoAddToPolicyByDefault &&
isTrustedFolder &&
allowPermanentApproval
) {
const isSafeToPersist =
confirmationDetails.type === 'info' ||
confirmationDetails.type === 'edit' ||
(confirmationDetails.type === 'exec' &&
confirmationDetails.rootCommand) ||
confirmationDetails.type === 'mcp';
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
};
}
if (confirmationDetails.type === 'exit_plan_mode') {
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
getPreferredEditor={getPreferredEditor}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode,
});
}}
onFeedback={(feedback) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [], securityWarnings: null };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
if (isSafeToPersist) {
const alwaysAndSaveIndex = options.findIndex(
(o) => o.value === ToolConfirmationOutcome.ProceedAlwaysAndSave,
);
if (alwaysAndSaveIndex !== -1) {
initialIndex = alwaysAndSaveIndex;
}
}
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
if (executionProps.commands && executionProps.commands.length > 1) {
question = `Allow execution of ${executionProps.commands.length} commands?`;
} else {
question = `Allow execution of: '${sanitizeForDisplay(executionProps.rootCommand)}'?`;
if (deceptiveUrlWarningText) {
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<DiffRenderer
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
filename={sanitizeForDisplay(confirmationDetails.fileName)}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={terminalWidth}
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const commandsToDisplay =
executionProps.commands && executionProps.commands.length > 1
? executionProps.commands
: [executionProps.command];
const containsRedirection = commandsToDisplay.some((cmd) =>
hasRedirection(cmd),
);
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
initialIndex: 0,
};
}
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
REDIRECTION_WARNING_NOTE_LABEL.length +
REDIRECTION_WARNING_NOTE_TEXT.length;
const tipLength =
REDIRECTION_WARNING_TIP_LABEL.length +
REDIRECTION_WARNING_TIP_TEXT.length;
if (confirmationDetails.type === 'exit_plan_mode') {
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
getPreferredEditor={getPreferredEditor}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode,
});
}}
onFeedback={(feedback) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
initialIndex: 0,
};
}
const noteLines = Math.ceil(noteLength / safeWidth);
const tipLines = Math.ceil(tipLength / safeWidth);
const spacerLines = 1;
const warningHeight = noteLines + tipLines + spacerLines;
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
if (executionProps.commands && executionProps.commands.length > 1) {
question = `Allow execution of ${executionProps.commands.length} commands?`;
} else {
question = `Allow execution of: '${sanitizeForDisplay(executionProps.rootCommand)}'?`;
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
bodyContent = (
<DiffRenderer
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
filename={sanitizeForDisplay(confirmationDetails.fileName)}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={terminalWidth}
/>
);
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const commandsToDisplay =
executionProps.commands && executionProps.commands.length > 1
? executionProps.commands
: [executionProps.command];
const containsRedirection = commandsToDisplay.some((cmd) =>
hasRedirection(cmd),
);
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight = Math.max(
bodyContentHeight - warningHeight,
MINIMUM_MAX_HEIGHT,
bodyContentHeight -= 2; // Account for padding;
}
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
REDIRECTION_WARNING_NOTE_LABEL.length +
REDIRECTION_WARNING_NOTE_TEXT.length;
const tipLength =
REDIRECTION_WARNING_TIP_LABEL.length +
REDIRECTION_WARNING_TIP_TEXT.length;
const noteLines = Math.ceil(noteLength / safeWidth);
const tipLines = Math.ceil(tipLength / safeWidth);
const spacerLines = 1;
const warningHeight = noteLines + tipLines + spacerLines;
if (bodyContentHeight !== undefined) {
bodyContentHeight = Math.max(
bodyContentHeight - warningHeight,
MINIMUM_MAX_HEIGHT,
);
}
warnings = (
<>
<Box height={1} />
<Box>
<Text color={theme.text.primary}>
<Text bold>{REDIRECTION_WARNING_NOTE_LABEL}</Text>
{REDIRECTION_WARNING_NOTE_TEXT}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{REDIRECTION_WARNING_TIP_TEXT}
</Text>
</Box>
</>
);
}
warnings = (
<>
<Box height={1} />
<Box>
<Text color={theme.text.primary}>
<Text bold>{REDIRECTION_WARNING_NOTE_LABEL}</Text>
{REDIRECTION_WARNING_NOTE_TEXT}
bodyContent = (
<Box flexDirection="column">
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
<Box flexDirection="column">
{commandsToDisplay.map((cmd, idx) => (
<Text key={idx} color={theme.text.link}>
{sanitizeForDisplay(cmd)}
</Text>
))}
</Box>
</MaxSizedBox>
{warnings}
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(
infoProps.urls.length === 1 &&
infoProps.urls[0] === infoProps.prompt
);
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.link}>
<RenderInline
text={infoProps.prompt}
defaultColor={theme.text.link}
/>
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((urlString) => (
<Text key={urlString}>
{' '}
- <RenderInline text={toUnicodeUrl(urlString)} />
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
bodyContent = (
<Box flexDirection="column">
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{REDIRECTION_WARNING_TIP_TEXT}
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</Box>
</>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool
details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
</Box>
);
}
bodyContent = (
<Box flexDirection="column">
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
<Box flexDirection="column">
{commandsToDisplay.map((cmd, idx) => (
<Text key={idx} color={theme.text.link}>
{sanitizeForDisplay(cmd)}
</Text>
))}
</Box>
</MaxSizedBox>
{warnings}
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(
infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt
);
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.link}>
<RenderInline
text={infoProps.prompt}
defaultColor={theme.text.link}
/>
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((urlString) => (
<Text key={urlString}>
{' '}
- <RenderInline text={toUnicodeUrl(urlString)} />
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
bodyContent = (
<Box flexDirection="column">
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
</Box>
);
}
return { question, bodyContent, options, securityWarnings };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
]);
return { question, bodyContent, options, securityWarnings, initialIndex };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
settings.merged.security.autoAddToPolicyByDefault,
isTrustedFolder,
allowPermanentApproval,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
@@ -697,6 +734,7 @@ export const ToolConfirmationMessage: React.FC<
items={options}
onSelect={handleSelect}
isFocused={isFocused}
initialIndex={initialIndex}
/>
</Box>
</>
@@ -1,5 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationMessage > enablePermanentToolApproval setting > should default to "Allow for all future sessions" when autoAddToPolicyByDefault is true 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
1. Allow once
2. Allow for this session
● 3. Allow for all future sessions
4. Modify with external editor
5. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"echo "hello"
ls -la
+7
View File
@@ -548,6 +548,7 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
workspacePoliciesDir?: string;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
@@ -740,6 +741,7 @@ export class Config implements McpContext {
private readonly fileExclusions: FileExclusions;
private readonly eventEmitter?: EventEmitter;
private readonly useWriteTodos: boolean;
private readonly workspacePoliciesDir: string | undefined;
private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private policyUpdateConfirmationRequest:
@@ -951,6 +953,7 @@ export class Config implements McpContext {
this.useWriteTodos = isPreviewModel(this.model)
? false
: (params.useWriteTodos ?? true);
this.workspacePoliciesDir = params.workspacePoliciesDir;
this.enableHooksUI = params.enableHooksUI ?? true;
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
@@ -1956,6 +1959,10 @@ export class Config implements McpContext {
return this.geminiMdFilePaths;
}
getWorkspacePoliciesDir(): string | undefined {
return this.workspacePoliciesDir;
}
setGeminiMdFilePaths(paths: string[]): void {
this.geminiMdFilePaths = paths;
}
+7
View File
@@ -168,6 +168,13 @@ export class Storage {
return path.join(this.getGeminiDir(), 'policies');
}
getWorkspaceAutoSavedPolicyPath(): string {
return path.join(
this.getWorkspacePoliciesDir(),
AUTO_SAVED_POLICY_FILENAME,
);
}
getAutoSavedPolicyPath(): string {
return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME);
}
@@ -118,6 +118,7 @@ export interface UpdatePolicy {
type: MessageBusType.UPDATE_POLICY;
toolName: string;
persist?: boolean;
persistScope?: 'workspace' | 'user';
argsPattern?: string;
commandPrefix?: string | string[];
mcpName?: string;
+13 -1
View File
@@ -520,9 +520,21 @@ export function createPolicyUpdater(
if (message.persist) {
persistenceQueue = persistenceQueue.then(async () => {
try {
const policyFile = storage.getAutoSavedPolicyPath();
const policyFile =
message.persistScope === 'workspace'
? storage.getWorkspaceAutoSavedPolicyPath()
: storage.getAutoSavedPolicyPath();
await fs.mkdir(path.dirname(policyFile), { recursive: true });
// Backup existing file if it exists
try {
await fs.copyFile(policyFile, `${policyFile}.bak`);
} catch (error) {
if (!isNodeError(error) || error.code !== 'ENOENT') {
debugLogger.warn(`Failed to backup ${policyFile}`, error);
}
}
// Read existing file
let existingData: { rule?: TomlRule[] } = {};
try {
+76 -4
View File
@@ -230,15 +230,87 @@ describe('createPolicyUpdater', () => {
// Note: @iarna/toml optimizes for shortest representation, so it may use single quotes 'foo"bar'
// instead of "foo\"bar\"" if there are no single quotes in the string.
try {
expect(writtenContent).toContain(`mcpName = "my\\"jira\\"server"`);
expect(writtenContent).toContain('mcpName = "my\\"jira\\"server"');
} catch {
expect(writtenContent).toContain(`mcpName = 'my"jira"server'`);
expect(writtenContent).toContain('mcpName = \'my"jira"server\'');
}
try {
expect(writtenContent).toContain(`toolName = "search\\"tool\\""`);
expect(writtenContent).toContain('toolName = "search\\"tool\\""');
} catch {
expect(writtenContent).toContain(`toolName = 'search"tool"'`);
expect(writtenContent).toContain('toolName = \'search"tool"\'');
}
});
it('should persist to workspace when persistScope is workspace', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspaceAutoSavedPolicyPath').mockReturnValue(
policyFile,
);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
new Error('File not found'),
);
(fs.copyFile as unknown as Mock).mockResolvedValue(undefined);
const mockFileHandle = {
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
(fs.open as unknown as Mock).mockResolvedValue(mockFileHandle);
(fs.rename as unknown as Mock).mockResolvedValue(undefined);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test_tool',
persist: true,
persistScope: 'workspace',
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockStorage.getWorkspaceAutoSavedPolicyPath).toHaveBeenCalled();
expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, {
recursive: true,
});
expect(fs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\.tmp$/),
policyFile,
);
});
it('should backup existing policy file before writing', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockResolvedValue(
'[[rule]]\ntoolName = "existing"',
);
(fs.copyFile as unknown as Mock).mockResolvedValue(undefined);
const mockFileHandle = {
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
(fs.open as unknown as Mock).mockResolvedValue(mockFileHandle);
(fs.rename as unknown as Mock).mockResolvedValue(undefined);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'new_tool',
persist: true,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fs.copyFile).toHaveBeenCalledWith(policyFile, `${policyFile}.bak`);
});
});
+12
View File
@@ -82,3 +82,15 @@ export function buildArgsPatterns(
return [argsPattern];
}
/**
* Builds a regex pattern to match a specific file path in tool arguments.
* This is used to narrow tool approvals for edit tools to specific files.
*
* @param filePath The relative path to the file.
* @returns A regex string that matches "file_path":"<path>" in a JSON string.
*/
export function buildFilePathArgsPattern(filePath: string): string {
const jsonPath = JSON.stringify(filePath).slice(1, -1);
return `"file_path":"${escapeRegex(jsonPath)}"`;
}
+94 -1
View File
@@ -16,7 +16,10 @@ import {
import { checkPolicy, updatePolicy, getPolicyDenialError } from './policy.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
MessageBusType,
type SerializableConfirmationDetails,
} from '../confirmation-bus/types.js';
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import {
ToolConfirmationOutcome,
@@ -198,6 +201,8 @@ describe('policy.ts', () => {
it('should handle standard policy updates with persistence', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
@@ -408,6 +413,8 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysAndSave (persist: true)', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
@@ -439,6 +446,92 @@ describe('policy.ts', () => {
}),
);
});
it('should determine persistScope: workspace in trusted folders', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(true),
getWorkspacePoliciesDir: vi
.fn()
.mockReturnValue('/mock/project/policies'),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysAndSave,
undefined,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
persistScope: 'workspace',
}),
);
});
it('should determine persistScope: user in untrusted folders', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi
.fn()
.mockReturnValue('/mock/project/policies'),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysAndSave,
undefined,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
persistScope: 'user',
}),
);
});
it('should narrow edit tools with argsPattern', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'write_file' } as AnyDeclarativeTool;
const details = {
type: 'edit',
filePath: 'src/foo.ts',
title: 'Edit',
onConfirm: vi.fn(),
} as unknown as SerializableConfirmationDetails;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysAndSave,
details,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'write_file',
argsPattern: '"file_path":"src/foo\\.ts"',
}),
);
});
});
describe('getPolicyDenialError', () => {
+25
View File
@@ -22,6 +22,7 @@ import {
type AnyDeclarativeTool,
type PolicyUpdateOptions,
} from '../tools/tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js';
@@ -102,6 +103,20 @@ export async function updatePolicy(
return;
}
// Determine persist scope if we are persisting.
let persistScope: 'workspace' | 'user' | undefined;
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
// If folder is trusted and workspace policies are enabled, we prefer workspace scope.
if (
deps.config.isTrustedFolder() &&
deps.config.getWorkspacePoliciesDir()
) {
persistScope = 'workspace';
} else {
persistScope = 'user';
}
}
// Specialized Tools (MCP)
if (confirmationDetails?.type === 'mcp') {
await handleMcpPolicyUpdate(
@@ -109,6 +124,7 @@ export async function updatePolicy(
outcome,
confirmationDetails,
deps.messageBus,
persistScope,
);
return;
}
@@ -119,6 +135,7 @@ export async function updatePolicy(
outcome,
confirmationDetails,
deps.messageBus,
persistScope,
);
}
@@ -148,6 +165,7 @@ async function handleStandardPolicyUpdate(
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
messageBus: MessageBus,
persistScope?: 'workspace' | 'user',
): Promise<void> {
if (
outcome === ToolConfirmationOutcome.ProceedAlways ||
@@ -157,12 +175,17 @@ async function handleStandardPolicyUpdate(
if (confirmationDetails?.type === 'exec') {
options.commandPrefix = confirmationDetails.rootCommands;
} else if (confirmationDetails?.type === 'edit') {
options.argsPattern = buildFilePathArgsPattern(
confirmationDetails.filePath,
);
}
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: tool.name,
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
persistScope,
...options,
});
}
@@ -180,6 +203,7 @@ async function handleMcpPolicyUpdate(
{ type: 'mcp' }
>,
messageBus: MessageBus,
persistScope?: 'workspace' | 'user',
): Promise<void> {
const isMcpAlways =
outcome === ToolConfirmationOutcome.ProceedAlways ||
@@ -204,5 +228,6 @@ async function handleMcpPolicyUpdate(
toolName,
mcpName: confirmationDetails.serverName,
persist,
persistScope,
});
}
+10
View File
@@ -20,7 +20,9 @@ import {
type ToolLocation,
type ToolResult,
type ToolResultDisplay,
type PolicyUpdateOptions,
} from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
@@ -442,6 +444,14 @@ class EditToolInvocation
return [{ path: this.params.file_path }];
}
protected override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
private async attemptSelfCorrection(
params: EditToolParams,
currentContent: string,
+1
View File
@@ -74,6 +74,7 @@ export interface ToolInvocation<
* Options for policy updates that can be customized by tool invocations.
*/
export interface PolicyUpdateOptions {
argsPattern?: string;
commandPrefix?: string | string[];
mcpName?: string;
}
+10
View File
@@ -24,7 +24,9 @@ import {
type ToolLocation,
type ToolResult,
type ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
@@ -150,6 +152,14 @@ class WriteFileToolInvocation extends BaseToolInvocation<
return [{ path: this.resolvedPath }];
}
protected override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
override getDescription(): string {
const relativePath = makeRelative(
this.resolvedPath,