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