Compare commits

...

4 Commits

Author SHA1 Message Date
Spencer 750e7aedf9 docs(settings): update schema and docs for autoAddPolicy 2026-02-27 21:21:11 +00:00
Spencer 812aae3d5c merge main into split/auto-add 2026-02-27 21:12:08 +00:00
Spencer d5a8cf9620 feat(policy): implement auto-add feature with safeguards 2026-02-27 20:35:35 +00:00
Spencer c6ff82944f refactor(policy): add isSensitive plumbing and tool Kind.Write 2026-02-27 20:32:20 +00:00
32 changed files with 1226 additions and 108 deletions
+1
View File
@@ -125,6 +125,7 @@ they appear in the UI.
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Auto-add to Policy | `security.autoAddPolicy` | Automatically add "Proceed always" approvals to your persistent policy. | `true` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
+5
View File
@@ -866,6 +866,11 @@ their corresponding top-level category object in your `settings.json` file.
confirmation dialogs.
- **Default:** `false`
- **`security.autoAddPolicy`** (boolean):
- **Description:** Automatically add "Proceed always" approvals to your
persistent policy.
- **Default:** `true`
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
+2 -2
View File
@@ -818,9 +818,10 @@ export async function loadCliConfig(
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
autoAddPolicy:
settings.security?.autoAddPolicy && !settings.admin?.secureModeEnabled,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
@@ -843,7 +844,6 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
+10
View File
@@ -1480,6 +1480,16 @@ const SETTINGS_SCHEMA = {
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
autoAddPolicy: {
type: 'boolean',
label: 'Auto-add to Policy',
category: 'Security',
requiresRestart: false,
default: true,
description:
'Automatically add "Proceed always" approvals to your persistent policy.',
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
+7
View File
@@ -594,6 +594,13 @@ export async function main() {
const messageBus = config.getMessageBus();
createPolicyUpdater(policyEngine, messageBus, config.storage);
// Listen for settings changes to update reactive config properties
coreEvents.on(CoreEvent.SettingsChanged, () => {
if (settings.merged.security.autoAddPolicy !== undefined) {
config.setAutoAddPolicy(settings.merged.security.autoAddPolicy);
}
});
// Register SessionEnd hook to fire on graceful exit
// This runs before telemetry shutdown in runExitCleanup()
registerCleanup(async () => {
+16
View File
@@ -501,7 +501,9 @@ export interface ConfigParameters {
experimentalZedIntegration?: boolean;
listSessions?: boolean;
deleteSession?: string;
autoAddPolicy?: boolean;
listExtensions?: boolean;
extensionLoader?: ExtensionLoader;
enabledExtensions?: string[];
enableExtensionReloading?: boolean;
@@ -657,6 +659,7 @@ export class Config implements McpContext {
private _activeModel: string;
private readonly maxSessionTurns: number;
private readonly autoAddPolicy: boolean;
private readonly listSessions: boolean;
private readonly deleteSession: string | undefined;
private readonly listExtensions: boolean;
@@ -892,6 +895,7 @@ export class Config implements McpContext {
params.experimentalZedIntegration ?? false;
this.listSessions = params.listSessions ?? false;
this.deleteSession = params.deleteSession;
this.autoAddPolicy = params.autoAddPolicy ?? false;
this.listExtensions = params.listExtensions ?? false;
this._extensionLoader =
params.extensionLoader ?? new SimpleExtensionLoader([]);
@@ -2215,6 +2219,18 @@ export class Config implements McpContext {
return this.bugCommand;
}
getAutoAddPolicy(): boolean {
if (this.disableYoloMode) {
return false;
}
return this.autoAddPolicy;
}
setAutoAddPolicy(value: boolean): void {
// @ts-expect-error - readonly property
this.autoAddPolicy = value;
}
getFileService(): FileDiscoveryService {
if (!this.fileDiscoveryService) {
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, {
@@ -121,6 +121,7 @@ export interface UpdatePolicy {
argsPattern?: string;
commandPrefix?: string | string[];
mcpName?: string;
isSensitive?: boolean;
}
export interface ToolPolicyRejection {
+206
View File
@@ -0,0 +1,206 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import * as fs from 'node:fs/promises';
import { createPolicyUpdater } from './config.js';
import {
MessageBusType,
type UpdatePolicy,
} from '../confirmation-bus/types.js';
import { coreEvents } from '../utils/events.js';
import type { PolicyEngine } from './policy-engine.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { Storage } from '../config/storage.js';
vi.mock('node:fs/promises');
vi.mock('../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
},
}));
describe('Policy Auto-add Safeguards', () => {
let policyEngine: Mocked<PolicyEngine>;
let messageBus: Mocked<MessageBus>;
let storage: Mocked<Storage>;
let updateCallback: (msg: UpdatePolicy) => Promise<void>;
beforeEach(() => {
policyEngine = {
addRule: vi.fn(),
} as unknown as Mocked<PolicyEngine>;
messageBus = {
subscribe: vi.fn((type, cb) => {
if (type === MessageBusType.UPDATE_POLICY) {
updateCallback = cb;
}
}),
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
storage = {
getWorkspacePoliciesDir: vi.fn().mockReturnValue('/tmp/policies'),
getAutoSavedPolicyPath: vi
.fn()
.mockReturnValue('/tmp/policies/autosaved.toml'),
} as unknown as Mocked<Storage>;
const enoent = new Error('ENOENT');
(enoent as NodeJS.ErrnoException).code = 'ENOENT';
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockRejectedValue(enoent);
vi.mocked(fs.open).mockResolvedValue({
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
} as unknown as fs.FileHandle);
vi.mocked(fs.rename).mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should skip persistence for wildcard toolName', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
expect(updateCallback).toBeDefined();
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: '*',
persist: true,
});
expect(fs.open).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Policy for all tools was not auto-saved'),
);
});
it('should skip persistence for broad argsPattern (.*)', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
argsPattern: '.*',
persist: true,
});
expect(fs.open).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('was not auto-saved for safety reasons'),
);
});
it('should allow persistence for specific argsPattern', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
argsPattern: '.*"file_path":"test.txt".*',
persist: true,
});
await vi.waitFor(() => {
expect(fs.open).toHaveBeenCalled();
});
expect(fs.rename).toHaveBeenCalledWith(
expect.stringContaining('autosaved.toml'),
'/tmp/policies/autosaved.toml',
);
});
it('should skip persistence for sensitive tool with no pattern', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'sensitive-tool',
isSensitive: true,
persist: true,
});
await vi.waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'Broad approval for "sensitive-tool" was not auto-saved',
),
);
});
expect(fs.open).not.toHaveBeenCalled();
});
it('should skip persistence for MCP tool with no pattern', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
const mcpToolName = 'mcp-server__sensitive-tool';
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: mcpToolName,
mcpName: 'mcp-server',
persist: true,
});
await vi.waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
`Broad approval for "${mcpToolName}" was not auto-saved`,
),
);
});
expect(fs.open).not.toHaveBeenCalled();
});
it('should de-duplicate identical rules when auto-saving', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
// First call: file doesn't exist (ENOENT already mocked in beforeEach)
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'read_file',
argsPattern: '.*"file_path":"test.txt".*',
persist: true,
isSensitive: true,
});
await vi.waitFor(() => {
expect(fs.open).toHaveBeenCalledTimes(1);
});
// Mock file existing with the rule for the second call
vi.mocked(fs.readFile).mockResolvedValue(
'[[rule]]\ntoolName = "read_file"\ndecision = "allow"\npriority = 100\nargsPattern = \'.*"file_path":"test.txt".*\'\n',
);
// Second call: should skip persistence because it's a duplicate
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'read_file',
argsPattern: '.*"file_path":"test.txt".*',
persist: true,
isSensitive: true,
});
// Still only 1 call to fs.open
expect(fs.open).toHaveBeenCalledTimes(1);
});
});
+64
View File
@@ -514,6 +514,40 @@ export function createPolicyUpdater(
}
if (message.persist) {
// Validation safeguards for auto-adding to persistent policy
if (!toolName || toolName === '*') {
coreEvents.emitFeedback(
'warning',
'Policy for all tools was not auto-saved for safety reasons. You can add it manually to your policy file if desired.',
);
return;
}
const broadPatternRegex = /^\s*(\^)?\.\*(\$)?\s*$/;
if (
message.argsPattern &&
broadPatternRegex.test(message.argsPattern)
) {
coreEvents.emitFeedback(
'warning',
`Policy for "${toolName}" with all arguments was not auto-saved for safety reasons. You can add it manually to your policy file if desired.`,
);
return;
}
const isMcpTool = !!message.mcpName;
if (
(message.isSensitive || isMcpTool) &&
!message.argsPattern &&
!message.commandPrefix
) {
coreEvents.emitFeedback(
'warning',
`Broad approval for "${toolName}" was not auto-saved for safety reasons. Approvals for sensitive tools must be specific to be auto-saved.`,
);
return;
}
persistenceQueue = persistenceQueue.then(async () => {
try {
const policyFile = storage.getAutoSavedPolicyPath();
@@ -570,6 +604,36 @@ export function createPolicyUpdater(
newRule.argsPattern = message.argsPattern;
}
// De-duplicate: check if an identical rule already exists
const isDuplicate = existingData.rule.some((r) => {
const matchesTool = r.toolName === newRule.toolName;
const matchesMcp = r.mcpName === newRule.mcpName;
const matchesPattern = r.argsPattern === newRule.argsPattern;
const rPrefix = r.commandPrefix;
const newPrefix = newRule.commandPrefix;
let matchesPrefix = false;
if (Array.isArray(rPrefix) && Array.isArray(newPrefix)) {
matchesPrefix =
rPrefix.length === newPrefix.length &&
rPrefix.every((v, i) => v === newPrefix[i]);
} else {
matchesPrefix = rPrefix === newPrefix;
}
return (
matchesTool && matchesMcp && matchesPattern && matchesPrefix
);
});
if (isDuplicate) {
debugLogger.debug(
`Policy rule for ${toolName} already exists in persistent policy, skipping.`,
);
return;
}
// Add to rules
existingData.rule.push(newRule);
@@ -179,6 +179,7 @@ describe('createPolicyUpdater', () => {
toolName,
persist: true,
mcpName,
argsPattern: '{"issue": ".*"}',
});
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -189,6 +190,7 @@ describe('createPolicyUpdater', () => {
const writtenContent = writeCall[0] as string;
expect(writtenContent).toContain(`mcpName = "${mcpName}"`);
expect(writtenContent).toContain(`toolName = "${simpleToolName}"`);
expect(writtenContent).toContain(`argsPattern = '{"issue": ".*"}'`);
expect(writtenContent).toContain('priority = 200');
});
@@ -218,6 +220,7 @@ describe('createPolicyUpdater', () => {
toolName,
persist: true,
mcpName,
argsPattern: '{"query": ".*"}',
});
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -240,5 +243,7 @@ describe('createPolicyUpdater', () => {
} catch {
expect(writtenContent).toContain(`toolName = 'search"tool"'`);
}
expect(writtenContent).toContain(`argsPattern = '{"query": ".*"}'`);
});
});
+14
View File
@@ -11,6 +11,20 @@ export function escapeRegex(text: string): string {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s"]/g, '\\$&');
}
/**
* Escapes a string for use in a regular expression that matches a JSON-stringified value.
*
* This is necessary because some characters (like backslashes and quotes) are
* escaped twice in the final JSON string representation used for policy matching.
*/
export function escapeJsonRegex(text: string): string {
// 1. Get the JSON-escaped version of the string (e.g. C:\foo -> C:\\foo)
// 2. Remove the surrounding quotes
const jsonEscaped = JSON.stringify(text).slice(1, -1);
// 3. Regex-escape the result (e.g. C:\\foo -> C:\\\\foo)
return escapeRegex(jsonEscaped);
}
/**
* Basic validation for regular expressions to prevent common ReDoS patterns.
* This is a heuristic check and not a substitute for a full ReDoS scanner.
@@ -0,0 +1,241 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, type Mocked } from 'vitest';
import { updatePolicy } 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 {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
type ToolEditConfirmationDetails,
} from '../tools/tools.js';
import {
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
} from '../tools/tool-names.js';
describe('Scheduler Auto-add Policy Logic', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should set persist: true for ProceedAlways if autoAddPolicy is enabled', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.ProceedAlways, undefined, {
config: mockConfig,
messageBus: mockMessageBus,
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
persist: true,
}),
);
});
it('should set persist: false for ProceedAlways if autoAddPolicy is disabled', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.ProceedAlways, undefined, {
config: mockConfig,
messageBus: mockMessageBus,
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
persist: false,
}),
);
});
it('should generate specific argsPattern for edit tools', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: WRITE_FILE_TOOL_NAME,
isSensitive: true,
} as AnyDeclarativeTool;
const details: ToolEditConfirmationDetails = {
type: 'edit',
title: 'Confirm Write',
fileName: 'test.txt',
filePath: 'test.txt',
fileDiff: '',
originalContent: '',
newContent: '',
onConfirm: vi.fn(),
};
await updatePolicy(tool, ToolConfirmationOutcome.ProceedAlways, details, {
config: mockConfig,
messageBus: mockMessageBus,
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
argsPattern: expect.stringMatching(/test.*txt/),
isSensitive: true,
}),
);
});
it.each([
{
name: 'read_file',
tool: {
name: READ_FILE_TOOL_NAME,
params: { file_path: 'read.me' },
},
pattern: /read\\.me/,
},
{
name: 'list_directory',
tool: {
name: LS_TOOL_NAME,
params: { dir_path: './src' },
},
pattern: /\.\/src/,
},
{
name: 'glob',
tool: {
name: GLOB_TOOL_NAME,
params: { dir_path: './packages' },
},
pattern: /\.\/packages/,
},
{
name: 'grep_search',
tool: {
name: GREP_TOOL_NAME,
params: { dir_path: './src' },
},
pattern: /\.\/src/,
},
{
name: 'read_many_files',
tool: {
name: READ_MANY_FILES_TOOL_NAME,
params: { include: ['src/**/*.ts', 'test/'] },
},
pattern: /include.*src.*ts.*test/,
},
{
name: 'web_fetch',
tool: {
name: WEB_FETCH_TOOL_NAME,
params: { prompt: 'Summarize https://example.com/page' },
},
pattern: /example\\.com/,
},
{
name: 'web_fetch (direct url)',
tool: {
name: WEB_FETCH_TOOL_NAME,
params: { url: 'https://google.com' },
},
pattern: /"url":"https:\/\/google\\.com"/,
},
{
name: 'web_search',
tool: {
name: WEB_SEARCH_TOOL_NAME,
params: { query: 'how to bake bread' },
},
pattern: /how\\ to\\ bake\\ bread/,
},
{
name: 'write_todos',
tool: {
name: WRITE_TODOS_TOOL_NAME,
params: { todos: [{ description: 'fix the bug', status: 'pending' }] },
},
pattern: /fix\\ the\\ bug/,
},
{
name: 'read_file (Windows path)',
tool: {
name: READ_FILE_TOOL_NAME,
params: { file_path: 'C:\\foo\\bar.txt' },
},
pattern: /C:\\\\\\\\foo\\\\\\\\bar\\.txt/,
},
])(
'should generate specific argsPattern for $name',
async ({ tool, pattern }) => {
const toolWithSensitive = {
...tool,
isSensitive: true,
} as unknown as AnyDeclarativeTool;
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
await updatePolicy(
toolWithSensitive,
ToolConfirmationOutcome.ProceedAlways,
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
},
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: tool.name,
argsPattern: expect.stringMatching(pattern),
isSensitive: true,
}),
);
},
);
});
+136 -12
View File
@@ -150,13 +150,17 @@ describe('policy.ts', () => {
describe('updatePolicy', () => {
it('should set AUTO_EDIT mode for auto-edit transition tools', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'replace' } as AnyDeclarativeTool; // 'replace' is in EDIT_TOOL_NAMES
const tool = {
name: 'replace',
isSensitive: false,
} as AnyDeclarativeTool; // 'replace' is in EDIT_TOOL_NAMES
await updatePolicy(
tool,
@@ -168,17 +172,27 @@ describe('policy.ts', () => {
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'replace',
persist: false,
}),
);
});
it('should handle standard policy updates (persist=false)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
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;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(
tool,
@@ -198,12 +212,16 @@ describe('policy.ts', () => {
it('should handle standard policy updates with persistence', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
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;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(
tool,
@@ -223,12 +241,16 @@ describe('policy.ts', () => {
it('should handle shell command prefixes', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'run_shell_command' } as AnyDeclarativeTool;
const tool = {
name: 'run_shell_command',
isSensitive: true,
} as AnyDeclarativeTool;
const details: ToolExecuteConfirmationDetails = {
type: 'exec',
command: 'ls -la',
@@ -254,12 +276,16 @@ describe('policy.ts', () => {
it('should handle MCP policy updates (server scope)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -288,12 +314,16 @@ describe('policy.ts', () => {
it('should NOT publish update for ProceedOnce', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
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;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.ProceedOnce, undefined, {
config: mockConfig,
@@ -306,12 +336,16 @@ describe('policy.ts', () => {
it('should NOT publish update for Cancel', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
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;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.Cancel, undefined, {
config: mockConfig,
@@ -323,12 +357,16 @@ describe('policy.ts', () => {
it('should NOT publish update for ModifyWithEditor', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
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;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(
tool,
@@ -342,12 +380,16 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysTool (specific tool name)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -376,12 +418,16 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlways (persist: false)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -408,12 +454,16 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysAndSave (persist: true)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -436,6 +486,80 @@ describe('policy.ts', () => {
toolName: 'mcp-tool',
mcpName: 'my-server',
persist: true,
argsPattern: '\\{.*\\}', // Fix verified: specific pattern provided for auto-add
}),
);
});
it('should NOT provide argsPattern for server-wide MCP approvals', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
toolName: 'mcp-tool',
toolDisplayName: 'My Tool',
title: 'MCP',
onConfirm: vi.fn(),
};
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysServer,
details,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'my-server__*',
mcpName: 'my-server',
persist: false, // Server-wide approvals currently do not auto-persist for safety
argsPattern: undefined, // Server-wide approvals are intentionally not specific
}),
);
});
it('should handle specificity for read_many_files', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'read_many_files',
isSensitive: true,
params: { include: ['file1.ts', 'file2.ts'] },
} as unknown as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlways,
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
},
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'read_many_files',
persist: true,
argsPattern: expect.stringContaining('file1\\.ts'),
}),
);
});
+165 -6
View File
@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { escapeJsonRegex } from '../policy/utils.js';
import { ToolErrorType } from '../tools/tool-error.js';
import {
MCP_QUALIFIED_NAME_SEPARATOR,
DiscoveredMCPTool,
} from '../tools/mcp-tool.js';
import {
ApprovalMode,
PolicyDecision,
@@ -22,10 +27,37 @@ import {
type AnyDeclarativeTool,
type PolicyUpdateOptions,
} from '../tools/tools.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import {
EDIT_TOOL_NAMES,
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
} from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js';
interface ToolWithParams {
params: Record<string, unknown>;
}
function hasParams(
tool: AnyDeclarativeTool,
): tool is AnyDeclarativeTool & ToolWithParams {
const t = tool as unknown;
return (
typeof t === 'object' &&
t !== null &&
'params' in t &&
typeof (t as { params: unknown }).params === 'object' &&
(t as { params: unknown }).params !== null
);
}
/**
* Helper to format the policy denial error.
*/
@@ -99,7 +131,6 @@ export async function updatePolicy(
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
deps.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
return;
}
// Specialized Tools (MCP)
@@ -108,6 +139,7 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.config,
deps.messageBus,
);
return;
@@ -118,6 +150,7 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.config,
deps.messageBus,
);
}
@@ -139,6 +172,99 @@ function isAutoEditTransition(
);
}
type SpecificityGenerator = (
tool: AnyDeclarativeTool,
confirmationDetails?: SerializableConfirmationDetails,
) => string | undefined;
const specificityGenerators: Record<string, SpecificityGenerator> = {
[READ_FILE_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const filePath = tool.params['file_path'];
if (typeof filePath !== 'string') return undefined;
const escapedPath = escapeJsonRegex(filePath);
return `.*"file_path":"${escapedPath}".*`;
},
[LS_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const dirPath = tool.params['dir_path'];
if (typeof dirPath !== 'string') return undefined;
const escapedPath = escapeJsonRegex(dirPath);
return `.*"dir_path":"${escapedPath}".*`;
},
[GLOB_TOOL_NAME]: (tool) => specificityGenerators[LS_TOOL_NAME](tool),
[GREP_TOOL_NAME]: (tool) => specificityGenerators[LS_TOOL_NAME](tool),
[READ_MANY_FILES_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const include = tool.params['include'];
if (!Array.isArray(include) || include.length === 0) return undefined;
const lookaheads = include
.map((p) => escapeJsonRegex(String(p)))
.map((p) => `(?=.*"${p}")`)
.join('');
const pattern = `.*"include":\\[${lookaheads}.*\\].*`;
// Limit regex length for safety
if (pattern.length > 2048) {
return '.*"include":\\[.*\\].*';
}
return pattern;
},
[WEB_FETCH_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const url = tool.params['url'];
if (typeof url === 'string') {
const escaped = escapeJsonRegex(url);
return `.*"url":"${escaped}".*`;
}
const prompt = tool.params['prompt'];
if (typeof prompt !== 'string') return undefined;
const urlMatches = prompt.matchAll(/https?:\/\/[^\s"']+/g);
const urls = Array.from(urlMatches)
.map((m) => m[0])
.slice(0, 3);
if (urls.length === 0) return undefined;
const lookaheads = urls
.map((u) => escapeJsonRegex(u))
.map((u) => `(?=.*${u})`)
.join('');
return `.*${lookaheads}.*`;
},
[WEB_SEARCH_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const query = tool.params['query'];
if (typeof query === 'string') {
const escaped = escapeJsonRegex(query);
return `.*"query":"${escaped}".*`;
}
// Fallback to a pattern that matches any arguments
// but isn't just ".*" to satisfy the auto-add safeguard.
return '\\{.*\\}';
},
[WRITE_TODOS_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const todos = tool.params['todos'];
if (!Array.isArray(todos)) return undefined;
const escaped = todos
.filter(
(v): v is { description: string } => typeof v?.description === 'string',
)
.map((v) => escapeJsonRegex(v.description))
.join('|');
if (!escaped) return undefined;
return `.*"todos":\\[.*(?:${escaped}).*\\].*`;
},
[GET_INTERNAL_DOCS_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const filePath = tool.params['file_path'];
if (typeof filePath !== 'string') return undefined;
const escaped = escapeJsonRegex(filePath);
return `.*"file_path":"${escaped}".*`;
},
};
/**
* Handles policy updates for standard tools (Shell, Info, etc.), including
* session-level and persistent approvals.
@@ -147,6 +273,7 @@ async function handleStandardPolicyUpdate(
tool: AnyDeclarativeTool,
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
config: Config,
messageBus: MessageBus,
): Promise<void> {
if (
@@ -159,10 +286,27 @@ async function handleStandardPolicyUpdate(
options.commandPrefix = confirmationDetails.rootCommands;
}
if (confirmationDetails?.type === 'edit') {
// Generate a specific argsPattern for file edits to prevent broad approvals
const escapedPath = escapeJsonRegex(confirmationDetails.filePath);
options.argsPattern = `.*"file_path":"${escapedPath}".*`;
} else {
const generator = specificityGenerators[tool.name];
if (generator) {
options.argsPattern = generator(tool, confirmationDetails);
}
}
const persist =
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
(outcome === ToolConfirmationOutcome.ProceedAlways &&
config.getAutoAddPolicy());
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: tool.name,
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
persist,
isSensitive: tool.isSensitive,
...options,
});
}
@@ -179,6 +323,7 @@ async function handleMcpPolicyUpdate(
SerializableConfirmationDetails,
{ type: 'mcp' }
>,
config: Config,
messageBus: MessageBus,
): Promise<void> {
const isMcpAlways =
@@ -192,17 +337,31 @@ async function handleMcpPolicyUpdate(
}
let toolName = tool.name;
const persist = outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave;
const persist =
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
(outcome === ToolConfirmationOutcome.ProceedAlways &&
config.getAutoAddPolicy());
// If "Always allow all tools from this server", use the wildcard pattern
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
toolName = `${confirmationDetails.serverName}__*`;
toolName = `${confirmationDetails.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}*`;
}
// MCP tools are treated as sensitive, so we MUST provide a specific argsPattern
// or commandPrefix to satisfy the auto-add safeguard in createPolicyUpdater.
// For single-tool approvals, we default to a pattern that matches the JSON structure
// of the arguments string (e.g. \{.*\}).
const argsPattern =
outcome !== ToolConfirmationOutcome.ProceedAlwaysServer
? '\\{.*\\}'
: undefined;
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName,
mcpName: confirmationDetails.serverName,
persist,
isSensitive: tool.isSensitive,
argsPattern,
});
}
+11 -2
View File
@@ -43,7 +43,15 @@ class ActivateSkillToolInvocation extends BaseToolInvocation<
_toolName?: string,
_toolDisplayName?: string,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
true, // ActivateSkill is always sensitive
);
}
getDescription(): string {
@@ -185,13 +193,14 @@ export class ActivateSkillTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
_isSensitive?: boolean,
): ToolInvocation<ActivateSkillToolParams, ToolResult> {
return new ActivateSkillToolInvocation(
this.config,
params,
messageBus,
_toolName,
_toolDisplayName ?? 'Activate Skill',
_toolDisplayName,
);
}
+17 -1
View File
@@ -434,8 +434,17 @@ class EditToolInvocation
messageBus: MessageBus,
toolName?: string,
displayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, toolName, displayName);
super(
params,
messageBus,
toolName,
displayName,
undefined,
undefined,
isSensitive,
);
}
override toolLocations(): ToolLocation[] {
@@ -956,6 +965,9 @@ export class EditTool
messageBus,
true, // isOutputMarkdown
false, // canUpdateOutput
undefined,
undefined,
true,
);
}
@@ -1001,6 +1013,9 @@ export class EditTool
protected createInvocation(
params: EditToolParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<EditToolParams, ToolResult> {
return new EditToolInvocation(
this.config,
@@ -1008,6 +1023,7 @@ export class EditTool
messageBus,
this.name,
this.displayName,
isSensitive,
);
}
+15 -1
View File
@@ -79,8 +79,17 @@ class GetInternalDocsInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
override async shouldConfirmExecute(
@@ -165,6 +174,9 @@ export class GetInternalDocsTool extends BaseDeclarativeTool<
messageBus,
/* isOutputMarkdown */ true,
/* canUpdateOutput */ false,
undefined,
undefined,
true,
);
}
@@ -173,12 +185,14 @@ export class GetInternalDocsTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<GetInternalDocsParams, ToolResult> {
return new GetInternalDocsInvocation(
params,
messageBus,
_toolName ?? GetInternalDocsTool.Name,
_toolDisplayName,
isSensitive,
);
}
+15 -1
View File
@@ -96,8 +96,17 @@ class GlobToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
getDescription(): string {
@@ -278,6 +287,9 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
messageBus,
true,
false,
undefined,
undefined,
true,
);
}
@@ -328,6 +340,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<GlobToolParams, ToolResult> {
return new GlobToolInvocation(
this.config,
@@ -335,6 +348,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+15 -1
View File
@@ -83,8 +83,17 @@ class GrepToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
this.fileExclusions = config.getFileExclusions();
}
@@ -601,6 +610,9 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
messageBus,
true,
false,
undefined,
undefined,
true,
);
}
@@ -676,6 +688,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<GrepToolParams, ToolResult> {
return new GrepToolInvocation(
this.config,
@@ -683,6 +696,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+16 -2
View File
@@ -78,8 +78,17 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
/**
@@ -293,6 +302,9 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
messageBus,
true,
false,
undefined,
undefined,
true,
);
}
@@ -316,13 +328,15 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<LSToolParams, ToolResult> {
return new LSToolInvocation(
this.config,
params,
messageBus ?? this.messageBus,
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+5
View File
@@ -93,6 +93,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
private readonly toolDescription?: string,
private readonly toolParameterSchema?: unknown,
toolAnnotationsData?: Record<string, unknown>,
isSensitive: boolean = false,
) {
// Use composite format for policy checks: serverName__toolName
// This enables server wildcards (e.g., "google-workspace__*")
@@ -105,6 +106,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
displayName,
serverName,
toolAnnotationsData,
isSensitive,
);
}
@@ -283,6 +285,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
false, // canUpdateOutput,
extensionName,
extensionId,
true, // isSensitive
);
this._isReadOnly = isReadOnly;
}
@@ -331,6 +334,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_displayName?: string,
isSensitive?: boolean,
): ToolInvocation<ToolParams, ToolResult> {
return new DiscoveredMCPToolInvocation(
this.mcpTool,
@@ -344,6 +348,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.description,
this.parameterSchema,
this._toolAnnotations,
isSensitive,
);
}
}
+3
View File
@@ -285,6 +285,9 @@ export class MemoryTool
messageBus,
true,
false,
undefined,
undefined,
true,
);
}
+99 -71
View File
@@ -11,7 +11,6 @@ import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import type { PartUnion } from '@google/genai';
import {
processSingleFileContent,
getSpecificMimeType,
@@ -45,20 +44,29 @@ export interface ReadFileToolParams {
*/
end_line?: number;
}
class ReadFileToolInvocation extends BaseToolInvocation<
ReadFileToolParams,
ToolResult
> {
private readonly resolvedPath: string;
constructor(
private config: Config,
private readonly config: Config,
params: ReadFileToolParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
@@ -97,66 +105,80 @@ class ReadFileToolInvocation extends BaseToolInvocation<
},
};
}
try {
const result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
);
const result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
);
if (result.error) {
return {
llmContent: result.llmContent,
returnDisplay: result.returnDisplay || 'Error reading file',
error: {
message: result.error,
type: result.errorType,
},
};
}
if (result.error) {
return {
llmContent: result.llmContent,
returnDisplay: result.returnDisplay || 'Error reading file',
error: {
message: result.error,
type: result.errorType,
},
};
}
let llmContent = result.llmContent;
let llmContent: PartUnion;
if (result.isTruncated) {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
llmContent = `
if (result.isTruncated && typeof llmContent === 'string') {
const [startLine, endLine] = result.linesShown || [1, 0];
llmContent = `
IMPORTANT: The file content has been truncated.
Status: Showing lines ${start}-${end} of ${total} total lines.
Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: ${end + 1}.
Status: Showing lines ${startLine}-${endLine} of ${result.originalLineCount} total lines.
Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: ${
endLine + 1
}.
--- FILE CONTENT (truncated) ---
${result.llmContent}`;
} else {
llmContent = result.llmContent || '';
${llmContent}
`;
}
const programming_language = getProgrammingLanguage({
file_path: this.resolvedPath,
});
logFileOperation(
this.config,
new FileOperationEvent(
this._toolName || READ_FILE_TOOL_NAME,
FileOperation.READ,
result.originalLineCount,
getSpecificMimeType(this.resolvedPath),
path.extname(this.resolvedPath),
programming_language,
),
);
const finalResult: ToolResult = {
llmContent,
returnDisplay: result.returnDisplay || '',
};
return finalResult;
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
const errorMessage = String(error.message);
const toolResult: ToolResult = {
llmContent: [
{
text: `Error reading file: ${errorMessage}`,
},
],
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
};
return toolResult;
}
const lines =
typeof result.llmContent === 'string'
? result.llmContent.split('\n').length
: undefined;
const mimetype = getSpecificMimeType(this.resolvedPath);
const programming_language = getProgrammingLanguage({
file_path: this.resolvedPath,
});
logFileOperation(
this.config,
new FileOperationEvent(
READ_FILE_TOOL_NAME,
FileOperation.READ,
lines,
mimetype,
path.extname(this.resolvedPath),
programming_language,
),
);
return {
llmContent,
returnDisplay: result.returnDisplay || '',
};
}
}
@@ -183,6 +205,9 @@ export class ReadFileTool extends BaseDeclarativeTool<
messageBus,
true,
false,
undefined,
undefined,
true,
);
this.fileDiscoveryService = new FileDiscoveryService(
config.getTargetDir(),
@@ -193,29 +218,18 @@ export class ReadFileTool extends BaseDeclarativeTool<
protected override validateToolParamValues(
params: ReadFileToolParams,
): string | null {
if (params.file_path.trim() === '') {
if (!params.file_path) {
return "The 'file_path' parameter must be non-empty.";
}
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.file_path,
);
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
);
if (validationError) {
return validationError;
}
if (params.start_line !== undefined && params.start_line < 1) {
return 'start_line must be at least 1';
}
if (params.end_line !== undefined && params.end_line < 1) {
return 'end_line must be at least 1';
}
if (
params.start_line !== undefined &&
params.end_line !== undefined &&
@@ -224,6 +238,18 @@ export class ReadFileTool extends BaseDeclarativeTool<
return 'start_line cannot be greater than end_line';
}
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.file_path,
);
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
);
if (validationError) {
return validationError;
}
const fileFilteringOptions = this.config.getFileFilteringOptions();
if (
this.fileDiscoveryService.shouldIgnoreFile(
@@ -242,6 +268,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<ReadFileToolParams, ToolResult> {
return new ReadFileToolInvocation(
this.config,
@@ -249,6 +276,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+15 -1
View File
@@ -114,8 +114,17 @@ class ReadManyFilesToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
getDescription(): string {
@@ -474,6 +483,9 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
messageBus,
true, // isOutputMarkdown
false, // canUpdateOutput
undefined,
undefined,
true,
);
}
@@ -482,6 +494,7 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<ReadManyFilesParams, ToolResult> {
return new ReadManyFilesToolInvocation(
this.config,
@@ -489,6 +502,7 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+16 -2
View File
@@ -167,8 +167,17 @@ class GrepToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
async execute(signal: AbortSignal): Promise<ToolResult> {
@@ -584,6 +593,9 @@ export class RipGrepTool extends BaseDeclarativeTool<
messageBus,
true, // isOutputMarkdown
false, // canUpdateOutput
undefined,
undefined,
true,
);
this.fileDiscoveryService = new FileDiscoveryService(
config.getTargetDir(),
@@ -665,14 +677,16 @@ export class RipGrepTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<RipGrepToolParams, ToolResult> {
return new GrepToolInvocation(
this.config,
this.fileDiscoveryService,
params,
messageBus ?? this.messageBus,
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+15 -1
View File
@@ -68,8 +68,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
getDescription(): string {
@@ -479,6 +488,9 @@ export class ShellTool extends BaseDeclarativeTool<
messageBus,
false, // output is not markdown
true, // output can be updated
undefined,
undefined,
true,
);
}
@@ -504,6 +516,7 @@ export class ShellTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<ShellToolParams, ToolResult> {
return new ShellToolInvocation(
this.config,
@@ -511,6 +524,7 @@ export class ShellTool extends BaseDeclarativeTool<
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+41
View File
@@ -75,6 +75,7 @@ export interface ToolInvocation<
export interface PolicyUpdateOptions {
commandPrefix?: string | string[];
mcpName?: string;
argsPattern?: string;
}
/**
@@ -92,6 +93,7 @@ export abstract class BaseToolInvocation<
readonly _toolDisplayName?: string,
readonly _serverName?: string,
readonly _toolAnnotations?: Record<string, unknown>,
readonly isSensitive: boolean = false,
) {}
abstract getDescription(): string;
@@ -152,6 +154,7 @@ export abstract class BaseToolInvocation<
type: MessageBusType.UPDATE_POLICY,
toolName: this._toolName,
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
isSensitive: this.isSensitive,
...options,
});
}
@@ -340,6 +343,11 @@ export interface ToolBuilder<
*/
isReadOnly: boolean;
/**
* Whether the tool is sensitive and requires specific policy approvals.
*/
isSensitive: boolean;
/**
* Validates raw parameters and builds a ready-to-execute invocation.
* @param params The raw, untrusted parameters from the model.
@@ -368,6 +376,7 @@ export abstract class DeclarativeTool<
readonly canUpdateOutput: boolean = false,
readonly extensionName?: string,
readonly extensionId?: string,
readonly isSensitive: boolean = false,
) {}
get isReadOnly(): boolean {
@@ -498,6 +507,34 @@ export abstract class BaseDeclarativeTool<
TParams extends object,
TResult extends ToolResult,
> extends DeclarativeTool<TParams, TResult> {
constructor(
name: string,
displayName: string,
description: string,
kind: Kind,
parameterSchema: unknown,
messageBus: MessageBus,
isOutputMarkdown: boolean = true,
canUpdateOutput: boolean = false,
extensionName?: string,
extensionId?: string,
isSensitive: boolean = false,
) {
super(
name,
displayName,
description,
kind,
parameterSchema,
messageBus,
isOutputMarkdown,
canUpdateOutput,
extensionName,
extensionId,
isSensitive,
);
}
build(params: TParams): ToolInvocation<TParams, TResult> {
const validationError = this.validateToolParams(params);
if (validationError) {
@@ -508,6 +545,7 @@ export abstract class BaseDeclarativeTool<
this.messageBus,
this.name,
this.displayName,
this.isSensitive,
);
}
@@ -533,6 +571,7 @@ export abstract class BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<TParams, TResult>;
}
@@ -826,6 +865,7 @@ export enum ToolConfirmationOutcome {
export enum Kind {
Read = 'read',
Write = 'write',
Edit = 'edit',
Delete = 'delete',
Move = 'move',
@@ -842,6 +882,7 @@ export enum Kind {
// Function kinds that have side effects
export const MUTATOR_KINDS: Kind[] = [
Kind.Write,
Kind.Edit,
Kind.Delete,
Kind.Move,
+15 -1
View File
@@ -178,8 +178,17 @@ class WebFetchToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
@@ -689,6 +698,9 @@ export class WebFetchTool extends BaseDeclarativeTool<
messageBus,
true, // isOutputMarkdown
false, // canUpdateOutput
undefined,
undefined,
true,
);
}
@@ -729,6 +741,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<WebFetchToolParams, ToolResult> {
return new WebFetchToolInvocation(
this.config,
@@ -736,6 +749,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+15 -1
View File
@@ -71,8 +71,17 @@ class WebSearchToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
override getDescription(): string {
@@ -208,6 +217,9 @@ export class WebSearchTool extends BaseDeclarativeTool<
messageBus,
true, // isOutputMarkdown
false, // canUpdateOutput
undefined,
undefined,
true,
);
}
@@ -230,6 +242,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<WebSearchToolParams, WebSearchToolResult> {
return new WebSearchToolInvocation(
this.config,
@@ -237,6 +250,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
messageBus ?? this.messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+18 -2
View File
@@ -136,8 +136,17 @@ class WriteFileToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
toolName?: string,
displayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, toolName, displayName);
super(
params,
messageBus,
toolName,
displayName,
undefined,
undefined,
isSensitive,
);
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
@@ -429,11 +438,14 @@ export class WriteFileTool
WriteFileTool.Name,
WRITE_FILE_DISPLAY_NAME,
WRITE_FILE_DEFINITION.base.description!,
Kind.Edit,
Kind.Write,
WRITE_FILE_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
undefined,
undefined,
true,
);
}
@@ -477,6 +489,9 @@ export class WriteFileTool
protected createInvocation(
params: WriteFileToolParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<WriteFileToolParams, ToolResult> {
return new WriteFileToolInvocation(
this.config,
@@ -484,6 +499,7 @@ export class WriteFileTool
messageBus ?? this.messageBus,
this.name,
this.displayName,
isSensitive,
);
}
+15 -1
View File
@@ -34,8 +34,17 @@ class WriteTodosToolInvocation extends BaseToolInvocation<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(params, messageBus, _toolName, _toolDisplayName);
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
}
getDescription(): string {
@@ -85,6 +94,9 @@ export class WriteTodosTool extends BaseDeclarativeTool<
messageBus,
true, // isOutputMarkdown
false, // canUpdateOutput
undefined,
undefined,
true,
);
}
@@ -128,12 +140,14 @@ export class WriteTodosTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_displayName?: string,
isSensitive?: boolean,
): ToolInvocation<WriteTodosToolParams, ToolResult> {
return new WriteTodosToolInvocation(
params,
messageBus,
_toolName,
_displayName,
isSensitive,
);
}
}
+7
View File
@@ -1451,6 +1451,13 @@
"default": false,
"type": "boolean"
},
"autoAddPolicy": {
"title": "Auto-add to Policy",
"description": "Automatically add \"Proceed always\" approvals to your persistent policy.",
"markdownDescription": "Automatically add \"Proceed always\" approvals to your persistent policy.\n\n- Category: `Security`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"blockGitExtensions": {
"title": "Blocks extensions from Git",
"description": "Blocks installing and loading extensions from Git.",