mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(policy): support auto-add to policy by default and scoped persistence
This commit is contained in:
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+16
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}"`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user