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.', 'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true, 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: { blockGitExtensions: {
type: 'boolean', type: 'boolean',
label: 'Blocks extensions from Git', label: 'Blocks extensions from Git',
@@ -9,12 +9,15 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as fs from 'node:fs/promises';
import { import {
type Config, type Config,
PolicyDecision, PolicyDecision,
ApprovalMode, ApprovalMode,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
describe('policiesCommand', () => { describe('policiesCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>; let mockContext: ReturnType<typeof createMockCommandContext>;
@@ -26,8 +29,9 @@ describe('policiesCommand', () => {
expect(policiesCommand.name).toBe('policies'); expect(policiesCommand.name).toBe('policies');
expect(policiesCommand.description).toBe('Manage policies'); expect(policiesCommand.description).toBe('Manage policies');
expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN); 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![0].name).toBe('list');
expect(policiesCommand.subCommands![1].name).toBe('undo');
}); });
describe('list subcommand', () => { describe('list subcommand', () => {
@@ -160,4 +164,63 @@ describe('policiesCommand', () => {
expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]'); 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 * SPDX-License-Identifier: Apache-2.0
*/ */
import * as fs from 'node:fs/promises';
import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core'; import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js'; import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } 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 = { export const policiesCommand: SlashCommand = {
name: 'policies', name: 'policies',
description: 'Manage policies', description: 'Manage policies',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: false, autoExecute: false,
subCommands: [listPoliciesCommand], subCommands: [listPoliciesCommand, undoPoliciesCommand],
}; };
@@ -406,6 +406,41 @@ describe('ToolConfirmationMessage', () => {
expect(lastFrame()).toContain('Allow for all future sessions'); expect(lastFrame()).toContain('Allow for all future sessions');
unmount(); 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', () => { describe('Modify with external editor option', () => {
@@ -386,255 +386,292 @@ export const ToolConfirmationMessage: React.FC<
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]); }, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options, securityWarnings } = useMemo<{ const { question, bodyContent, options, securityWarnings, initialIndex } =
question: string; useMemo<{
bodyContent: React.ReactNode; question: string;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>; bodyContent: React.ReactNode;
securityWarnings: React.ReactNode; options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
}>(() => { securityWarnings: React.ReactNode;
let bodyContent: React.ReactNode | null = null; initialIndex: number;
let securityWarnings: React.ReactNode | null = null; }>(() => {
let question = ''; let bodyContent: React.ReactNode | null = null;
const options = getOptions(); let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
if (deceptiveUrlWarningText) { let initialIndex = 0;
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />; 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') { if (isSafeToPersist) {
bodyContent = ( const alwaysAndSaveIndex = options.findIndex(
<AskUserDialog (o) => o.value === ToolConfirmationOutcome.ProceedAlwaysAndSave,
questions={confirmationDetails.questions} );
onSubmit={(answers) => { if (alwaysAndSaveIndex !== -1) {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); initialIndex = alwaysAndSaveIndex;
}} }
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?`;
} }
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
if (executionProps.commands && executionProps.commands.length > 1) { if (deceptiveUrlWarningText) {
question = `Allow execution of ${executionProps.commands.length} commands?`; securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
} 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.type === 'ask_user') {
if (!confirmationDetails.isModifying) {
bodyContent = ( bodyContent = (
<DiffRenderer <AskUserDialog
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)} questions={confirmationDetails.questions}
filename={sanitizeForDisplay(confirmationDetails.fileName)} onSubmit={(answers) => {
availableTerminalHeight={availableBodyContentHeight()} handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
terminalWidth={terminalWidth} }}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/> />
); );
} return {
} else if (confirmationDetails.type === 'exec') { question: '',
const executionProps = confirmationDetails; bodyContent,
options: [],
const commandsToDisplay = securityWarnings: null,
executionProps.commands && executionProps.commands.length > 1 initialIndex: 0,
? 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;
} }
if (containsRedirection) { if (confirmationDetails.type === 'exit_plan_mode') {
// Calculate lines needed for Note and Tip bodyContent = (
const safeWidth = Math.max(terminalWidth, 1); <ExitPlanModeDialog
const noteLength = planPath={confirmationDetails.planPath}
REDIRECTION_WARNING_NOTE_LABEL.length + getPreferredEditor={getPreferredEditor}
REDIRECTION_WARNING_NOTE_TEXT.length; onApprove={(approvalMode) => {
const tipLength = handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
REDIRECTION_WARNING_TIP_LABEL.length + approved: true,
REDIRECTION_WARNING_TIP_TEXT.length; 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); if (confirmationDetails.type === 'edit') {
const tipLines = Math.ceil(tipLength / safeWidth); if (!confirmationDetails.isModifying) {
const spacerLines = 1; question = `Apply this change?`;
const warningHeight = noteLines + tipLines + spacerLines; }
} 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) { if (bodyContentHeight !== undefined) {
bodyContentHeight = Math.max( bodyContentHeight -= 2; // Account for padding;
bodyContentHeight - warningHeight, }
MINIMUM_MAX_HEIGHT,
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 = ( bodyContent = (
<> <Box flexDirection="column">
<Box height={1} /> <MaxSizedBox
<Box> maxHeight={bodyContentHeight}
<Text color={theme.text.primary}> maxWidth={Math.max(terminalWidth, 1)}
<Text bold>{REDIRECTION_WARNING_NOTE_LABEL}</Text> >
{REDIRECTION_WARNING_NOTE_TEXT} <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>
</Box> <Text color={theme.text.link}>
<Box> Tool: {sanitizeForDisplay(mcpProps.toolName)}
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{REDIRECTION_WARNING_TIP_TEXT}
</Text> </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 = ( return { question, bodyContent, options, securityWarnings, initialIndex };
<Box flexDirection="column"> }, [
<MaxSizedBox confirmationDetails,
maxHeight={bodyContentHeight} getOptions,
maxWidth={Math.max(terminalWidth, 1)} availableBodyContentHeight,
> terminalWidth,
<Box flexDirection="column"> handleConfirm,
{commandsToDisplay.map((cmd, idx) => ( deceptiveUrlWarningText,
<Text key={idx} color={theme.text.link}> isMcpToolDetailsExpanded,
{sanitizeForDisplay(cmd)} hasMcpToolDetails,
</Text> mcpToolDetailsText,
))} expandDetailsHintKey,
</Box> getPreferredEditor,
</MaxSizedBox> settings.merged.security.autoAddToPolicyByDefault,
{warnings} isTrustedFolder,
</Box> allowPermanentApproval,
); ]);
} 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,
]);
const bodyOverflowDirection: 'top' | 'bottom' = const bodyOverflowDirection: 'top' | 'bottom' =
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
@@ -697,6 +734,7 @@ export const ToolConfirmationMessage: React.FC<
items={options} items={options}
onSelect={handleSelect} onSelect={handleSelect}
isFocused={isFocused} isFocused={isFocused}
initialIndex={initialIndex}
/> />
</Box> </Box>
</> </>
@@ -1,5 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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`] = ` exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"echo "hello" "echo "hello"
ls -la ls -la
+7
View File
@@ -548,6 +548,7 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number; truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter; eventEmitter?: EventEmitter;
useWriteTodos?: boolean; useWriteTodos?: boolean;
workspacePoliciesDir?: string;
policyEngineConfig?: PolicyEngineConfig; policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean; directWebFetch?: boolean;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
@@ -740,6 +741,7 @@ export class Config implements McpContext {
private readonly fileExclusions: FileExclusions; private readonly fileExclusions: FileExclusions;
private readonly eventEmitter?: EventEmitter; private readonly eventEmitter?: EventEmitter;
private readonly useWriteTodos: boolean; private readonly useWriteTodos: boolean;
private readonly workspacePoliciesDir: string | undefined;
private readonly messageBus: MessageBus; private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine; private readonly policyEngine: PolicyEngine;
private policyUpdateConfirmationRequest: private policyUpdateConfirmationRequest:
@@ -951,6 +953,7 @@ export class Config implements McpContext {
this.useWriteTodos = isPreviewModel(this.model) this.useWriteTodos = isPreviewModel(this.model)
? false ? false
: (params.useWriteTodos ?? true); : (params.useWriteTodos ?? true);
this.workspacePoliciesDir = params.workspacePoliciesDir;
this.enableHooksUI = params.enableHooksUI ?? true; this.enableHooksUI = params.enableHooksUI ?? true;
this.enableHooks = params.enableHooks ?? true; this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? []; this.disabledHooks = params.disabledHooks ?? [];
@@ -1956,6 +1959,10 @@ export class Config implements McpContext {
return this.geminiMdFilePaths; return this.geminiMdFilePaths;
} }
getWorkspacePoliciesDir(): string | undefined {
return this.workspacePoliciesDir;
}
setGeminiMdFilePaths(paths: string[]): void { setGeminiMdFilePaths(paths: string[]): void {
this.geminiMdFilePaths = paths; this.geminiMdFilePaths = paths;
} }
+7
View File
@@ -168,6 +168,13 @@ export class Storage {
return path.join(this.getGeminiDir(), 'policies'); return path.join(this.getGeminiDir(), 'policies');
} }
getWorkspaceAutoSavedPolicyPath(): string {
return path.join(
this.getWorkspacePoliciesDir(),
AUTO_SAVED_POLICY_FILENAME,
);
}
getAutoSavedPolicyPath(): string { getAutoSavedPolicyPath(): string {
return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME); return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME);
} }
@@ -118,6 +118,7 @@ export interface UpdatePolicy {
type: MessageBusType.UPDATE_POLICY; type: MessageBusType.UPDATE_POLICY;
toolName: string; toolName: string;
persist?: boolean; persist?: boolean;
persistScope?: 'workspace' | 'user';
argsPattern?: string; argsPattern?: string;
commandPrefix?: string | string[]; commandPrefix?: string | string[];
mcpName?: string; mcpName?: string;
+13 -1
View File
@@ -520,9 +520,21 @@ export function createPolicyUpdater(
if (message.persist) { if (message.persist) {
persistenceQueue = persistenceQueue.then(async () => { persistenceQueue = persistenceQueue.then(async () => {
try { try {
const policyFile = storage.getAutoSavedPolicyPath(); const policyFile =
message.persistScope === 'workspace'
? storage.getWorkspaceAutoSavedPolicyPath()
: storage.getAutoSavedPolicyPath();
await fs.mkdir(path.dirname(policyFile), { recursive: true }); 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 // Read existing file
let existingData: { rule?: TomlRule[] } = {}; let existingData: { rule?: TomlRule[] } = {};
try { 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' // 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. // instead of "foo\"bar\"" if there are no single quotes in the string.
try { try {
expect(writtenContent).toContain(`mcpName = "my\\"jira\\"server"`); expect(writtenContent).toContain('mcpName = "my\\"jira\\"server"');
} catch { } catch {
expect(writtenContent).toContain(`mcpName = 'my"jira"server'`); expect(writtenContent).toContain('mcpName = \'my"jira"server\'');
} }
try { try {
expect(writtenContent).toContain(`toolName = "search\\"tool\\""`); expect(writtenContent).toContain('toolName = "search\\"tool\\""');
} catch { } 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]; 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 { checkPolicy, updatePolicy, getPolicyDenialError } from './policy.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.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 { ApprovalMode, PolicyDecision } from '../policy/types.js';
import { import {
ToolConfirmationOutcome, ToolConfirmationOutcome,
@@ -198,6 +201,8 @@ describe('policy.ts', () => {
it('should handle standard policy updates with persistence', async () => { it('should handle standard policy updates with persistence', async () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>; } as unknown as Mocked<Config>;
const mockMessageBus = { const mockMessageBus = {
@@ -408,6 +413,8 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysAndSave (persist: true)', async () => { it('should handle MCP ProceedAlwaysAndSave (persist: true)', async () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>; } as unknown as Mocked<Config>;
const mockMessageBus = { 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', () => { describe('getPolicyDenialError', () => {
+25
View File
@@ -22,6 +22,7 @@ import {
type AnyDeclarativeTool, type AnyDeclarativeTool,
type PolicyUpdateOptions, type PolicyUpdateOptions,
} from '../tools/tools.js'; } from '../tools/tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js'; import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js'; import type { ValidatingToolCall } from './types.js';
@@ -102,6 +103,20 @@ export async function updatePolicy(
return; 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) // Specialized Tools (MCP)
if (confirmationDetails?.type === 'mcp') { if (confirmationDetails?.type === 'mcp') {
await handleMcpPolicyUpdate( await handleMcpPolicyUpdate(
@@ -109,6 +124,7 @@ export async function updatePolicy(
outcome, outcome,
confirmationDetails, confirmationDetails,
deps.messageBus, deps.messageBus,
persistScope,
); );
return; return;
} }
@@ -119,6 +135,7 @@ export async function updatePolicy(
outcome, outcome,
confirmationDetails, confirmationDetails,
deps.messageBus, deps.messageBus,
persistScope,
); );
} }
@@ -148,6 +165,7 @@ async function handleStandardPolicyUpdate(
outcome: ToolConfirmationOutcome, outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined, confirmationDetails: SerializableConfirmationDetails | undefined,
messageBus: MessageBus, messageBus: MessageBus,
persistScope?: 'workspace' | 'user',
): Promise<void> { ): Promise<void> {
if ( if (
outcome === ToolConfirmationOutcome.ProceedAlways || outcome === ToolConfirmationOutcome.ProceedAlways ||
@@ -157,12 +175,17 @@ async function handleStandardPolicyUpdate(
if (confirmationDetails?.type === 'exec') { if (confirmationDetails?.type === 'exec') {
options.commandPrefix = confirmationDetails.rootCommands; options.commandPrefix = confirmationDetails.rootCommands;
} else if (confirmationDetails?.type === 'edit') {
options.argsPattern = buildFilePathArgsPattern(
confirmationDetails.filePath,
);
} }
await messageBus.publish({ await messageBus.publish({
type: MessageBusType.UPDATE_POLICY, type: MessageBusType.UPDATE_POLICY,
toolName: tool.name, toolName: tool.name,
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave, persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
persistScope,
...options, ...options,
}); });
} }
@@ -180,6 +203,7 @@ async function handleMcpPolicyUpdate(
{ type: 'mcp' } { type: 'mcp' }
>, >,
messageBus: MessageBus, messageBus: MessageBus,
persistScope?: 'workspace' | 'user',
): Promise<void> { ): Promise<void> {
const isMcpAlways = const isMcpAlways =
outcome === ToolConfirmationOutcome.ProceedAlways || outcome === ToolConfirmationOutcome.ProceedAlways ||
@@ -204,5 +228,6 @@ async function handleMcpPolicyUpdate(
toolName, toolName,
mcpName: confirmationDetails.serverName, mcpName: confirmationDetails.serverName,
persist, persist,
persistScope,
}); });
} }
+10
View File
@@ -20,7 +20,9 @@ import {
type ToolLocation, type ToolLocation,
type ToolResult, type ToolResult,
type ToolResultDisplay, type ToolResultDisplay,
type PolicyUpdateOptions,
} from './tools.js'; } from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
@@ -442,6 +444,14 @@ class EditToolInvocation
return [{ path: this.params.file_path }]; return [{ path: this.params.file_path }];
} }
protected override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
private async attemptSelfCorrection( private async attemptSelfCorrection(
params: EditToolParams, params: EditToolParams,
currentContent: string, currentContent: string,
+1
View File
@@ -74,6 +74,7 @@ export interface ToolInvocation<
* Options for policy updates that can be customized by tool invocations. * Options for policy updates that can be customized by tool invocations.
*/ */
export interface PolicyUpdateOptions { export interface PolicyUpdateOptions {
argsPattern?: string;
commandPrefix?: string | string[]; commandPrefix?: string | string[];
mcpName?: string; mcpName?: string;
} }
+10
View File
@@ -24,7 +24,9 @@ import {
type ToolLocation, type ToolLocation,
type ToolResult, type ToolResult,
type ToolConfirmationOutcome, type ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from './tools.js'; } from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js';
@@ -150,6 +152,14 @@ class WriteFileToolInvocation extends BaseToolInvocation<
return [{ path: this.resolvedPath }]; return [{ path: this.resolvedPath }];
} }
protected override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
override getDescription(): string { override getDescription(): string {
const relativePath = makeRelative( const relativePath = makeRelative(
this.resolvedPath, this.resolvedPath,