Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeToolPermissionService.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, it } from 'vitest';
7
import type * as vscode from 'vscode';
8
import { IChatEndpoint } from '../../../../../platform/networking/common/networking';
9
import { Emitter } from '../../../../../util/vs/base/common/event';
10
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
11
import { constObservable, IObservable } from '../../../../../util/vs/base/common/observableInternal';
12
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
13
import { LanguageModelTextPart } from '../../../../../vscodeTypes';
14
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
15
import { ToolName } from '../../../../tools/common/toolNames';
16
import { ICopilotTool } from '../../../../tools/common/toolsRegistry';
17
import { IOnWillInvokeToolEvent, IToolsService, IToolValidationResult } from '../../../../tools/common/toolsService';
18
import { ClaudeToolPermissionContext, ClaudeToolPermissionResult, IClaudeToolConfirmationParams, IClaudeToolPermissionHandler } from '../../common/claudeToolPermission';
19
import { registerToolPermissionHandler } from '../../common/claudeToolPermissionRegistry';
20
import { ClaudeToolPermissionService } from '../../common/claudeToolPermissionService';
21
import { ClaudeToolNames } from '../../common/claudeTools';
22
23
// Import existing handlers to ensure they're registered
24
import '../../common/toolPermissionHandlers/index';
25
26
/**
27
* Mock tools service that can be configured for different test scenarios
28
*/
29
class MockToolsService implements IToolsService {
30
readonly _serviceBrand: undefined;
31
32
private readonly _onWillInvokeTool = new Emitter<IOnWillInvokeToolEvent>();
33
readonly onWillInvokeTool = this._onWillInvokeTool.event;
34
35
readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation> = [];
36
readonly copilotTools = new Map<ToolName, ICopilotTool<unknown>>();
37
38
private _confirmationResult: 'yes' | 'no' = 'yes';
39
private _optionsConfirmationResult: string | undefined;
40
private _invokeToolCalls: Array<{ name: string; input: unknown }> = [];
41
42
setConfirmationResult(result: 'yes' | 'no'): void {
43
this._confirmationResult = result;
44
}
45
46
setOptionsConfirmationResult(result: string | undefined): void {
47
this._optionsConfirmationResult = result;
48
}
49
50
get invokeToolCalls(): ReadonlyArray<{ name: string; input: unknown }> {
51
return this._invokeToolCalls;
52
}
53
54
clearCalls(): void {
55
this._invokeToolCalls = [];
56
}
57
58
invokeToolWithEndpoint(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>, endpoint: IChatEndpoint | undefined, token: vscode.CancellationToken): Thenable<vscode.LanguageModelToolResult2> {
59
return this.invokeTool(name, options);
60
}
61
62
modelSpecificTools: IObservable<{ definition: vscode.LanguageModelToolDefinition; tool: ICopilotTool<unknown> }[]> = constObservable([]);
63
64
async invokeTool(
65
name: string,
66
options: vscode.LanguageModelToolInvocationOptions<unknown>
67
): Promise<vscode.LanguageModelToolResult2> {
68
this._invokeToolCalls.push({ name, input: options.input });
69
70
if (name === ToolName.CoreConfirmationTool || name === ToolName.CoreTerminalConfirmationTool) {
71
return {
72
content: [new LanguageModelTextPart(this._confirmationResult)]
73
};
74
}
75
76
if (name === ToolName.CoreConfirmationToolWithOptions) {
77
return {
78
content: this._optionsConfirmationResult !== undefined
79
? [new LanguageModelTextPart(this._optionsConfirmationResult)]
80
: []
81
};
82
}
83
84
return { content: [] };
85
}
86
87
getCopilotTool(): ICopilotTool<unknown> | undefined {
88
return undefined;
89
}
90
91
getTool(): vscode.LanguageModelToolInformation | undefined {
92
return undefined;
93
}
94
95
getToolByToolReferenceName(): vscode.LanguageModelToolInformation | undefined {
96
return undefined;
97
}
98
99
validateToolInput(): IToolValidationResult {
100
return { inputObj: {} };
101
}
102
103
validateToolName(): string | undefined {
104
return undefined;
105
}
106
107
getEnabledTools(): vscode.LanguageModelToolInformation[] {
108
return [];
109
}
110
}
111
112
/**
113
* Creates a mock tool permission context
114
*/
115
function createMockContext(): ClaudeToolPermissionContext {
116
return {
117
toolInvocationToken: {} as vscode.ChatParticipantToolToken
118
};
119
}
120
121
describe('ClaudeToolPermissionService', () => {
122
let store: DisposableStore;
123
let instantiationService: IInstantiationService;
124
let mockToolsService: MockToolsService;
125
let service: ClaudeToolPermissionService;
126
127
beforeEach(() => {
128
store = new DisposableStore();
129
const serviceCollection = store.add(createExtensionUnitTestingServices());
130
131
mockToolsService = new MockToolsService();
132
serviceCollection.set(IToolsService, mockToolsService);
133
134
const accessor = serviceCollection.createTestingAccessor();
135
instantiationService = accessor.get(IInstantiationService);
136
service = instantiationService.createInstance(ClaudeToolPermissionService);
137
});
138
139
describe('canUseTool', () => {
140
describe('with default confirmation flow', () => {
141
it('allows tool when user confirms', async () => {
142
mockToolsService.setConfirmationResult('yes');
143
const input = { pattern: '**/*.ts' };
144
const context = createMockContext();
145
146
const result = await service.canUseTool(ClaudeToolNames.Glob, input, context);
147
148
expect(result.behavior).toBe('allow');
149
if (result.behavior === 'allow') {
150
expect(result.updatedInput).toEqual(input);
151
}
152
});
153
154
it('denies tool when user declines', async () => {
155
mockToolsService.setConfirmationResult('no');
156
const input = { pattern: '**/*.ts' };
157
const context = createMockContext();
158
159
const result = await service.canUseTool(ClaudeToolNames.Glob, input, context);
160
161
expect(result.behavior).toBe('deny');
162
if (result.behavior === 'deny') {
163
expect(result.message).toBe('The user declined to run the tool');
164
}
165
});
166
167
it('invokes CoreConfirmationTool with tool parameters', async () => {
168
const input = { pattern: 'test-pattern' };
169
const context = createMockContext();
170
171
await service.canUseTool(ClaudeToolNames.Glob, input, context);
172
173
expect(mockToolsService.invokeToolCalls.length).toBe(1);
174
expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreConfirmationTool);
175
176
const confirmParams = mockToolsService.invokeToolCalls[0].input as IClaudeToolConfirmationParams;
177
expect(confirmParams.title).toContain('Glob');
178
expect(confirmParams.message).toContain('test-pattern');
179
});
180
181
it('uses default confirmation when no handler registered', async () => {
182
const input = { some: 'data' };
183
const context = createMockContext();
184
185
// Use a tool that likely has no custom handler
186
await service.canUseTool('UnknownTool', input, context);
187
188
expect(mockToolsService.invokeToolCalls.length).toBe(1);
189
const confirmParams = mockToolsService.invokeToolCalls[0].input as IClaudeToolConfirmationParams;
190
expect(confirmParams.title).toContain('UnknownTool');
191
});
192
});
193
194
describe('with registered handler', () => {
195
it('uses handler handle method for Bash tool with terminal confirmation', async () => {
196
const input = { command: 'npm test' };
197
const context = createMockContext();
198
199
await service.canUseTool(ClaudeToolNames.Bash, input, context);
200
201
expect(mockToolsService.invokeToolCalls.length).toBe(1);
202
// Bash handler uses CoreTerminalConfirmationTool directly via its handle method
203
expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreTerminalConfirmationTool);
204
const terminalInput = mockToolsService.invokeToolCalls[0].input as { message: string; command: string; isBackground: boolean };
205
expect(terminalInput.command).toBe('npm test');
206
expect(terminalInput.isBackground).toBe(false);
207
});
208
209
it('bypasses confirmation when canAutoApprove returns true', async () => {
210
// Register a handler that auto-approves
211
class AutoApproveHandler implements IClaudeToolPermissionHandler<ClaudeToolNames.NotebookEdit> {
212
readonly toolNames = [ClaudeToolNames.NotebookEdit] as const;
213
214
async canAutoApprove(): Promise<boolean> {
215
return true;
216
}
217
}
218
registerToolPermissionHandler([ClaudeToolNames.NotebookEdit], AutoApproveHandler);
219
220
// Create a new service to pick up the handler
221
const newService = instantiationService.createInstance(ClaudeToolPermissionService);
222
const input = { notebook_path: '/test.ipynb' };
223
const context = createMockContext();
224
225
const result = await newService.canUseTool(ClaudeToolNames.NotebookEdit, input, context);
226
227
expect(result.behavior).toBe('allow');
228
expect(mockToolsService.invokeToolCalls.length).toBe(0);
229
});
230
231
it('uses full handle implementation when available', async () => {
232
const customResult: ClaudeToolPermissionResult = {
233
behavior: 'allow',
234
updatedInput: { modified: true }
235
};
236
237
// Register a handler with full handle implementation
238
class FullHandler implements IClaudeToolPermissionHandler<ClaudeToolNames.KillBash> {
239
readonly toolNames = [ClaudeToolNames.KillBash] as const;
240
241
async handle(): Promise<ClaudeToolPermissionResult> {
242
return customResult;
243
}
244
}
245
registerToolPermissionHandler([ClaudeToolNames.KillBash], FullHandler);
246
247
// Create a new service to pick up the handler
248
const newService = instantiationService.createInstance(ClaudeToolPermissionService);
249
const input = { pid: 123 };
250
const context = createMockContext();
251
252
const result = await newService.canUseTool(ClaudeToolNames.KillBash, input, context);
253
254
expect(result).toEqual(customResult);
255
expect(mockToolsService.invokeToolCalls.length).toBe(0);
256
});
257
});
258
259
describe('handler caching', () => {
260
it('caches handler instances for repeated calls', async () => {
261
const context = createMockContext();
262
263
// Call twice with the same tool
264
await service.canUseTool(ClaudeToolNames.Bash, { command: 'ls' }, context);
265
mockToolsService.clearCalls();
266
await service.canUseTool(ClaudeToolNames.Bash, { command: 'pwd' }, context);
267
268
// Both calls should succeed
269
expect(mockToolsService.invokeToolCalls.length).toBe(1);
270
// Bash handler uses CoreTerminalConfirmationTool directly via its handle method
271
expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreTerminalConfirmationTool);
272
const terminalInput = mockToolsService.invokeToolCalls[0].input as { message: string; command: string; isBackground: boolean };
273
expect(terminalInput.command).toBe('pwd');
274
});
275
});
276
277
describe('ExitPlanMode handler', () => {
278
const exitPlanModeInput = { plan: 'Step 1: Do something\nStep 2: Do another thing' };
279
280
it('allows when user clicks Approve', async () => {
281
mockToolsService.setOptionsConfirmationResult('Approve');
282
const context = createMockContext();
283
284
const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);
285
286
expect(result.behavior).toBe('allow');
287
if (result.behavior === 'allow') {
288
expect(result.updatedInput).toEqual(exitPlanModeInput);
289
}
290
});
291
292
it('invokes CoreConfirmationToolWithOptions with Approve and Deny buttons', async () => {
293
mockToolsService.setOptionsConfirmationResult('Approve');
294
const context = createMockContext();
295
296
await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);
297
298
expect(mockToolsService.invokeToolCalls.length).toBe(1);
299
expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreConfirmationToolWithOptions);
300
const input = mockToolsService.invokeToolCalls[0].input as { title: string; message: string; buttons: string[] };
301
expect(input.buttons).toEqual(['Approve', 'Deny']);
302
expect(input.message).toContain('Step 1: Do something');
303
});
304
305
it('denies when user clicks Deny', async () => {
306
mockToolsService.setOptionsConfirmationResult('Deny');
307
const context = createMockContext();
308
309
const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);
310
311
expect(result.behavior).toBe('deny');
312
if (result.behavior === 'deny') {
313
expect(result.message).toContain('declined');
314
}
315
});
316
317
it('denies when dialog returns empty content', async () => {
318
mockToolsService.setOptionsConfirmationResult(undefined);
319
const context = createMockContext();
320
321
const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);
322
323
expect(result.behavior).toBe('deny');
324
if (result.behavior === 'deny') {
325
expect(result.message).toContain('declined');
326
}
327
});
328
329
it('denies with distinct message when tool invocation throws', async () => {
330
const failingService = new class extends MockToolsService {
331
override async invokeTool(name: string): Promise<vscode.LanguageModelToolResult2> {
332
if (name === ToolName.CoreConfirmationToolWithOptions) {
333
throw new Error('Tool unavailable');
334
}
335
return { content: [] };
336
}
337
}();
338
339
const serviceCollection = store.add(createExtensionUnitTestingServices());
340
serviceCollection.set(IToolsService, failingService);
341
const accessor = serviceCollection.createTestingAccessor();
342
const newService = accessor.get(IInstantiationService).createInstance(ClaudeToolPermissionService);
343
344
const result = await newService.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, createMockContext());
345
346
expect(result.behavior).toBe('deny');
347
if (result.behavior === 'deny') {
348
expect(result.message).toBe('Failed to show plan confirmation');
349
}
350
});
351
352
it('handles missing plan gracefully', async () => {
353
mockToolsService.setOptionsConfirmationResult('Approve');
354
const context = createMockContext();
355
356
const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, {}, context);
357
358
expect(result.behavior).toBe('allow');
359
const input = mockToolsService.invokeToolCalls[0].input as { message: string };
360
expect(input.message).toContain('');
361
});
362
});
363
364
describe('error handling', () => {
365
it('denies when confirmation tool throws', async () => {
366
// Create a mock that throws
367
const failingService = new class extends MockToolsService {
368
override async invokeTool(): Promise<vscode.LanguageModelToolResult2> {
369
throw new Error('Confirmation failed');
370
}
371
}();
372
373
const serviceCollection = store.add(createExtensionUnitTestingServices());
374
serviceCollection.set(IToolsService, failingService);
375
const accessor = serviceCollection.createTestingAccessor();
376
const newInstantiationService = accessor.get(IInstantiationService);
377
const newService = newInstantiationService.createInstance(ClaudeToolPermissionService);
378
379
const result = await newService.canUseTool(ClaudeToolNames.Glob, {}, createMockContext());
380
381
expect(result.behavior).toBe('deny');
382
});
383
384
it('denies when confirmation returns empty content', async () => {
385
const emptyService = new class extends MockToolsService {
386
override async invokeTool(): Promise<vscode.LanguageModelToolResult2> {
387
return { content: [] };
388
}
389
}();
390
391
const serviceCollection = store.add(createExtensionUnitTestingServices());
392
serviceCollection.set(IToolsService, emptyService);
393
const accessor = serviceCollection.createTestingAccessor();
394
const newInstantiationService = accessor.get(IInstantiationService);
395
const newService = newInstantiationService.createInstance(ClaudeToolPermissionService);
396
397
const result = await newService.canUseTool(ClaudeToolNames.Glob, {}, createMockContext());
398
399
expect(result.behavior).toBe('deny');
400
});
401
});
402
});
403
});
404
405