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