Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts
13405 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 type { Attachment, PermissionRequestedEvent } from '@github/copilot/sdk';
7
import { platform } from 'node:os';
8
import type { CancellationToken, ChatParticipantToolToken, ChatResponseStream } from 'vscode';
9
import { ILogService } from '../../../../platform/log/common/logService';
10
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
11
import { extUriBiasedIgnorePathCase, isEqual } from '../../../../util/vs/base/common/resources';
12
import { URI } from '../../../../util/vs/base/common/uri';
13
import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
14
import { LanguageModelTextPart, Uri } from '../../../../vscodeTypes';
15
import { ToolName } from '../../../tools/common/toolNames';
16
import { IToolsService } from '../../../tools/common/toolsService';
17
import { createEditConfirmation, formatDiffAsUnified } from '../../../tools/node/editFileToolUtils';
18
import { ExternalEditTracker } from '../../common/externalEditTracker';
19
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';
20
import { getAffectedUrisForEditTool, getCdPresentationOverrides, ToolCall } from '../common/copilotCLITools';
21
import { getCopilotCLISessionStateDir } from './cliHelpers';
22
import { ICopilotCLIImageSupport } from './copilotCLIImageSupport';
23
24
type CoreTerminalConfirmationToolParams = {
25
tool: ToolName.CoreTerminalConfirmationTool;
26
input: {
27
message: string;
28
command: string | undefined;
29
isBackground: boolean;
30
};
31
};
32
33
type CoreConfirmationToolParams = {
34
tool: ToolName.CoreConfirmationTool;
35
input: {
36
title: string;
37
message: string;
38
confirmationType: 'basic';
39
};
40
};
41
42
/**
43
* The result of requesting permissions — the full union accepted by `Session.respondToPermission`.
44
* Extracted from the SDK's second parameter type to stay in sync automatically.
45
*/
46
export type PermissionRequestResult = Parameters<import('@github/copilot/sdk').Session['respondToPermission']>[1];
47
48
/**
49
* Handles `read` permission requests.
50
* Auto-approves reads for workspace files, session resources, trusted images, and attached files.
51
* Falls back to interactive confirmation for out-of-workspace reads.
52
*/
53
export async function handleReadPermission(
54
sessionId: string,
55
permissionRequest: Extract<PermissionRequest, { kind: 'read' }>,
56
toolParentCallId: string | undefined,
57
attachments: readonly Attachment[],
58
imageSupport: ICopilotCLIImageSupport,
59
workspaceInfo: IWorkspaceInfo,
60
workspaceService: IWorkspaceService,
61
toolsService: IToolsService,
62
toolInvocationToken: ChatParticipantToolToken,
63
logService: ILogService,
64
token: CancellationToken,
65
): Promise<PermissionRequestResult> {
66
const file = Uri.file(permissionRequest.path);
67
68
if (imageSupport.isTrustedImage(file)) {
69
return { kind: 'approve-once' };
70
}
71
72
if (isFileFromSessionWorkspace(file, workspaceInfo)) {
73
logService.trace(`[CopilotCLISession] Auto Approving request to read file in session workspace ${permissionRequest.path}`);
74
return { kind: 'approve-once' };
75
}
76
77
if (workspaceService.getWorkspaceFolder(file)) {
78
logService.trace(`[CopilotCLISession] Auto Approving request to read workspace file ${permissionRequest.path}`);
79
return { kind: 'approve-once' };
80
}
81
82
// Auto-approve reads of internal session resources (e.g. plan.md).
83
const sessionDir = Uri.joinPath(Uri.file(getCopilotCLISessionStateDir()), sessionId);
84
if (extUriBiasedIgnorePathCase.isEqualOrParent(file, sessionDir)) {
85
logService.trace(`[CopilotCLISession] Auto Approving request to read Copilot CLI session resource ${permissionRequest.path}`);
86
return { kind: 'approve-once' };
87
}
88
89
// Auto-approve if the file was explicitly attached by the user.
90
if (attachments.some(attachment => attachment.type === 'file' && isEqual(Uri.file(attachment.path), file))) {
91
logService.trace(`[CopilotCLISession] Auto Approving request to read attached file ${permissionRequest.path}`);
92
return { kind: 'approve-once' };
93
}
94
95
const toolParams: CoreConfirmationToolParams = {
96
tool: ToolName.CoreConfirmationTool,
97
input: {
98
title: 'Read file(s)',
99
message: permissionRequest.intention || permissionRequest.path || codeBlock(permissionRequest),
100
confirmationType: 'basic'
101
}
102
};
103
return invokeConfirmationTool(toolParams, toolParentCallId, toolsService, toolInvocationToken, logService, token);
104
}
105
106
/**
107
* Handles `write` permission requests.
108
* Auto-approves writes within workspace/working directory (respecting isolation mode
109
* and protected-file checks). Tracks edits via `ExternalEditTracker` when auto-approving.
110
* Falls back to interactive confirmation for writes outside the workspace or to protected files.
111
*/
112
export async function handleWritePermission(
113
sessionId: string,
114
permissionRequest: Extract<PermissionRequest, { kind: 'write' }>,
115
toolCall: ToolCall | undefined,
116
toolParentCallId: string | undefined,
117
stream: ChatResponseStream | undefined,
118
editTracker: ExternalEditTracker,
119
workspaceInfo: IWorkspaceInfo,
120
workspaceService: IWorkspaceService,
121
instantiationService: IInstantiationService,
122
toolsService: IToolsService,
123
toolInvocationToken: ChatParticipantToolToken,
124
logService: ILogService,
125
token: CancellationToken,
126
): Promise<PermissionRequestResult> {
127
const workingDirectory = getWorkingDirectory(workspaceInfo);
128
const editFile = getFileBeingEdited(permissionRequest, toolCall);
129
130
// Auto-approve writes within the workspace/working directory when appropriate.
131
if (workingDirectory && editFile) {
132
const isWorkspaceFile = workspaceService.getWorkspaceFolder(editFile);
133
const isWorkingDirectoryFile = !workspaceService.getWorkspaceFolder(workingDirectory) && extUriBiasedIgnorePathCase.isEqualOrParent(editFile, workingDirectory);
134
135
let autoApprove = false;
136
// If isolation is enabled, we only auto-approve writes within the working directory.
137
if (isIsolationEnabled(workspaceInfo) && isWorkingDirectoryFile) {
138
autoApprove = true;
139
}
140
// If its a workspace file, and not editing protected files, we auto-approve.
141
if (!autoApprove && isWorkspaceFile && !(await requiresFileEditconfirmation(instantiationService, permissionRequest, toolCall))) {
142
autoApprove = true;
143
}
144
// If we're working in the working directory (non-isolation), and not editing protected files, we auto-approve.
145
if (!autoApprove && isWorkingDirectoryFile && !(await requiresFileEditconfirmation(instantiationService, permissionRequest, toolCall, workingDirectory))) {
146
autoApprove = true;
147
}
148
149
if (autoApprove) {
150
logService.trace(`[CopilotCLISession] Auto Approving request ${editFile.fsPath}`);
151
await trackEditIfNeeded(editTracker, toolCall, editFile, stream, logService);
152
return { kind: 'approve-once' };
153
}
154
}
155
156
// Auto-approve writes to internal session resources (e.g. plan.md).
157
const sessionDir = Uri.joinPath(Uri.file(getCopilotCLISessionStateDir()), sessionId);
158
if (editFile && extUriBiasedIgnorePathCase.isEqualOrParent(editFile, sessionDir)) {
159
logService.trace(`[CopilotCLISession] Auto Approving request to write to Copilot CLI session resource ${editFile.fsPath}`);
160
return { kind: 'approve-once' };
161
}
162
163
// Fall back to interactive confirmation. If approved, track the edit.
164
let workspaceFolderForFile: URI | undefined;
165
if (editFile) {
166
workspaceFolderForFile = workspaceService.getWorkspaceFolder(editFile);
167
if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(editFile, workingDirectory)) {
168
workspaceFolderForFile = workingDirectory;
169
}
170
}
171
const toolParams = await getFileEditConfirmationToolParams(instantiationService, permissionRequest, toolCall, workspaceFolderForFile);
172
if (!toolParams) {
173
// No confirmation needed (e.g. no file to edit) — auto-approve.
174
if (editFile) {
175
await trackEditIfNeeded(editTracker, toolCall, editFile, stream, logService);
176
}
177
return { kind: 'approve-once' };
178
}
179
const result = await invokeConfirmationTool(toolParams, toolParentCallId, toolsService, toolInvocationToken, logService, token);
180
if (result.kind === 'approve-once' && editFile) {
181
await trackEditIfNeeded(editTracker, toolCall, editFile, stream, logService);
182
}
183
return result;
184
}
185
186
/**
187
* Handles `shell` permission requests.
188
* Builds a terminal confirmation prompt with the command text and intention,
189
* stripping `cd` prefixes that match the working directory for cleaner display.
190
*/
191
export async function handleShellPermission(
192
permissionRequest: Extract<PermissionRequest, { kind: 'shell' }>,
193
toolParentCallId: string | undefined,
194
workspaceInfo: IWorkspaceInfo,
195
toolsService: IToolsService,
196
toolInvocationToken: ChatParticipantToolToken,
197
logService: ILogService,
198
token: CancellationToken,
199
): Promise<PermissionRequestResult> {
200
const toolParams = buildShellConfirmationParams(permissionRequest, getWorkingDirectory(workspaceInfo));
201
return invokeConfirmationTool(toolParams, toolParentCallId, toolsService, toolInvocationToken, logService, token);
202
}
203
204
/**
205
* Builds the terminal confirmation tool params for a shell permission request.
206
* Pure function — no side effects, easy to test.
207
*/
208
export function buildShellConfirmationParams(
209
permissionRequest: Extract<PermissionRequest, { kind: 'shell' }>,
210
workingDirectory: URI | undefined,
211
isWindows?: boolean,
212
): CoreTerminalConfirmationToolParams {
213
isWindows = typeof isWindows === 'boolean' ? isWindows : platform() === 'win32';
214
const isPowershell = isWindows;
215
const fullCommandText = permissionRequest.fullCommandText || '';
216
const userFriendlyCommand = fullCommandText ? getCdPresentationOverrides(fullCommandText, isPowershell, workingDirectory)?.commandLine : undefined;
217
const command = userFriendlyCommand ?? fullCommandText;
218
219
return {
220
tool: ToolName.CoreTerminalConfirmationTool,
221
input: {
222
message: permissionRequest.intention || command || codeBlock(permissionRequest),
223
command,
224
isBackground: false
225
}
226
};
227
}
228
229
/**
230
* Handles `mcp` permission requests.
231
* Shows a confirmation dialog with the MCP server name, tool name, and arguments.
232
*/
233
export async function handleMcpPermission(
234
permissionRequest: Extract<PermissionRequest, { kind: 'mcp' }>,
235
toolParentCallId: string | undefined,
236
toolsService: IToolsService,
237
toolInvocationToken: ChatParticipantToolToken,
238
logService: ILogService,
239
token: CancellationToken,
240
): Promise<PermissionRequestResult> {
241
const toolParams = buildMcpConfirmationParams(permissionRequest);
242
return invokeConfirmationTool(toolParams, toolParentCallId, toolsService, toolInvocationToken, logService, token);
243
}
244
245
/**
246
* Builds the confirmation tool params for an MCP permission request.
247
* Pure function — no side effects, easy to test.
248
*/
249
export function buildMcpConfirmationParams(
250
permissionRequest: Extract<PermissionRequest, { kind: 'mcp' }>,
251
): CoreConfirmationToolParams {
252
const serverName = permissionRequest.serverName as string | undefined;
253
const toolTitle = permissionRequest.toolTitle as string | undefined;
254
const toolName = permissionRequest.toolName as string | undefined;
255
const args = permissionRequest.args;
256
257
return {
258
tool: ToolName.CoreConfirmationTool,
259
input: {
260
title: toolTitle || `MCP Tool: ${toolName || 'Unknown'}`,
261
message: serverName
262
? `Server: ${serverName}\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``
263
: `\`\`\`json\n${JSON.stringify(permissionRequest, null, 2)}\n\`\`\``,
264
confirmationType: 'basic'
265
}
266
};
267
}
268
269
/**
270
* Invokes a confirmation tool and returns a `PermissionRequestResult` based on the user's response.
271
*/
272
async function invokeConfirmationTool(
273
toolParams: CoreTerminalConfirmationToolParams | CoreConfirmationToolParams,
274
toolParentCallId: string | undefined,
275
toolsService: IToolsService,
276
toolInvocationToken: ChatParticipantToolToken,
277
logService: ILogService,
278
token: CancellationToken,
279
): Promise<PermissionRequestResult> {
280
try {
281
const { tool, input } = toolParams;
282
const result = await toolsService.invokeTool(tool, { input, toolInvocationToken, subAgentInvocationId: toolParentCallId }, token);
283
const firstResultPart = result.content.at(0);
284
if (firstResultPart instanceof LanguageModelTextPart && typeof firstResultPart.value === 'string' && firstResultPart.value.toLowerCase() === 'yes') {
285
return { kind: 'approve-once' };
286
}
287
} catch (error) {
288
logService.error(error, `[CopilotCLISession] Permission request error`);
289
}
290
return { kind: 'denied-interactively-by-user' };
291
}
292
293
/**
294
* Shows a generic interactive permission prompt to the user.
295
* Used as the fallback for permission kinds without a dedicated handler (url, memory, custom-tool, hook).
296
*/
297
export async function showInteractivePermissionPrompt(
298
permissionRequest: PermissionRequest,
299
toolParentCallId: string | undefined,
300
toolsService: IToolsService,
301
toolInvocationToken: ChatParticipantToolToken,
302
logService: ILogService,
303
token: CancellationToken,
304
): Promise<PermissionRequestResult> {
305
const toolParams: CoreConfirmationToolParams = {
306
tool: ToolName.CoreConfirmationTool,
307
input: {
308
title: 'Copilot CLI Permission Request',
309
message: codeBlock(permissionRequest),
310
confirmationType: 'basic'
311
}
312
};
313
return invokeConfirmationTool(toolParams, toolParentCallId, toolsService, toolInvocationToken, logService, token);
314
}
315
316
/**
317
* Checks whether a file belongs to the session's workspace, working directory,
318
* or repository (when using worktrees).
319
*/
320
export function isFileFromSessionWorkspace(file: URI, workspaceInfo: IWorkspaceInfo): boolean {
321
const workingDirectory = getWorkingDirectory(workspaceInfo);
322
if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(file, workingDirectory)) {
323
return true;
324
}
325
if (workspaceInfo.folder && extUriBiasedIgnorePathCase.isEqualOrParent(file, workspaceInfo.folder)) {
326
return true;
327
}
328
// Only if we have a worktree should we check the repository.
329
// As this means the user created a worktree and we have a repository.
330
// & if the worktree is automatically trusted, then so is the repository as we created the worktree from that.
331
if (workspaceInfo.worktree && workspaceInfo.repository && extUriBiasedIgnorePathCase.isEqualOrParent(file, workspaceInfo.repository)) {
332
return true;
333
}
334
return false;
335
}
336
337
/**
338
* Starts edit tracking if we have a tool call and a stream.
339
* This ensures the UI shows the edit-in-progress indicator and waits for core to acknowledge the edit.
340
*/
341
async function trackEditIfNeeded(editTracker: ExternalEditTracker, toolCall: ToolCall | undefined, editFile: URI, stream: ChatResponseStream | undefined, logService: ILogService): Promise<void> {
342
if (toolCall && stream) {
343
try {
344
await editTracker.trackEdit(toolCall.toolCallId, [editFile], stream);
345
} catch (error) {
346
logService.error(error, `[CopilotCLISession] Failed to track edit for toolCallId ${toolCall.toolCallId}`);
347
}
348
}
349
}
350
351
export async function requiresFileEditconfirmation(instaService: IInstantiationService, permissionRequest: PermissionRequest, toolCall?: ToolCall | undefined, workingDirectory?: URI): Promise<boolean> {
352
const confirmationInfo = await getFileEditConfirmationToolParams(instaService, permissionRequest, toolCall, workingDirectory);
353
return confirmationInfo !== undefined;
354
}
355
356
async function getFileEditConfirmationToolParams(instaService: IInstantiationService, permissionRequest: PermissionRequest, toolCall?: ToolCall | undefined, workingDirectory?: URI): Promise<CoreConfirmationToolParams | undefined> {
357
if (permissionRequest.kind !== 'write') {
358
return;
359
}
360
// Extract file name from the toolCall, thats more accurate, (as recommended by copilot cli sdk maintainers).
361
// The fileName in permission request is primarily for UI display purposes.
362
const file = getFileBeingEdited(permissionRequest, toolCall);
363
if (!file) {
364
return;
365
}
366
const details = async (accessor: ServicesAccessor) => {
367
if (!toolCall) {
368
return '';
369
} else if (toolCall.toolName === 'str_replace_editor' && toolCall.arguments.path) {
370
if (toolCall.arguments.command === 'edit' || toolCall.arguments.command === 'str_replace') {
371
return getDetailsForFileEditPermissionRequest(accessor, toolCall.arguments);
372
} else if (toolCall.arguments.command === 'create') {
373
return getDetailsForFileCreatePermissionRequest(accessor, toolCall.arguments);
374
} else if (toolCall.arguments.command === 'insert') {
375
return getDetailsForFileInsertPermissionRequest(accessor, toolCall.arguments);
376
}
377
} else if (toolCall.toolName === 'edit') {
378
return getDetailsForFileEditPermissionRequest(accessor, toolCall.arguments);
379
} else if (toolCall.toolName === 'create') {
380
return getDetailsForFileCreatePermissionRequest(accessor, toolCall.arguments);
381
} else if (toolCall.toolName === 'insert') {
382
return getDetailsForFileInsertPermissionRequest(accessor, toolCall.arguments);
383
}
384
};
385
386
const getDetails = () => instaService.invokeFunction(details).then(d => d || '');
387
const confirmationInfo = await instaService.invokeFunction(accessor => createEditConfirmation(accessor, [file], undefined, getDetails, undefined, () => workingDirectory));
388
const confirmationMessage = confirmationInfo.confirmationMessages;
389
if (!confirmationMessage) {
390
return;
391
}
392
393
return {
394
tool: ToolName.CoreConfirmationTool,
395
input: {
396
title: confirmationMessage.title,
397
message: typeof confirmationMessage.message === 'string' ? confirmationMessage.message : confirmationMessage.message.value,
398
confirmationType: 'basic'
399
}
400
};
401
}
402
403
async function getDetailsForFileInsertPermissionRequest(accessor: ServicesAccessor, args: Extract<ToolCall, { toolName: 'insert' }>['arguments']): Promise<string | undefined> {
404
if (args.path && args.new_str) {
405
return formatDiffAsUnified(accessor, URI.file(args.path), '', args.new_str);
406
}
407
}
408
async function getDetailsForFileCreatePermissionRequest(accessor: ServicesAccessor, args: Extract<ToolCall, { toolName: 'create' }>['arguments']): Promise<string | undefined> {
409
if (args.path && args.file_text) {
410
return formatDiffAsUnified(accessor, URI.file(args.path), '', args.file_text);
411
}
412
}
413
async function getDetailsForFileEditPermissionRequest(accessor: ServicesAccessor, args: Extract<ToolCall, { toolName: 'edit' | 'str_replace' }>['arguments']): Promise<string | undefined> {
414
if (args.path && (args.new_str || args.old_str)) {
415
return formatDiffAsUnified(accessor, URI.file(args.path), args.old_str ?? '', args.new_str ?? '');
416
}
417
}
418
419
export function getFileBeingEdited(permissionRequest: Extract<PermissionRequest, { kind: 'write' }>, toolCall?: ToolCall) {
420
// Get hold of file thats being edited if this is a edit tool call (requiring write permissions).
421
const editFiles = toolCall ? getAffectedUrisForEditTool(toolCall) : undefined;
422
// Sometimes we don't get a tool call id for the edit permission request
423
const editFile = editFiles && editFiles.length ? editFiles[0] : (permissionRequest.fileName ? URI.file(permissionRequest.fileName) : undefined);
424
return editFile;
425
}
426
function codeBlock(obj: Record<string, unknown>): string {
427
return `\n\n\`\`\`\n${JSON.stringify(obj, null, 2)}\n\`\`\``;
428
}
429
430
431
/** TYPES FROM @github/copilot */
432
433
/**
434
* A permission request which will be used to check tool or path usage against config and/or request user approval.
435
*/
436
export declare type PermissionRequest = PermissionRequestedEvent['data']['permissionRequest'];
437
438