From f1c294146ece0807561f782911396eb5fa1263d5 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 20 Aug 2026 16:12:59 +0200 Subject: [PATCH 1/2] warn when reading symlinked files --- .../tools/node/editFileToolUtils.tsx | 51 +------ .../src/extension/tools/node/readFileTool.tsx | 12 +- .../tools/node/searchSubagentTool.ts | 4 +- .../node/test/searchSubagentTool.spec.ts | 4 +- .../tools/node/test/toolUtils.spec.ts | 129 ++++++++++++++++-- .../src/extension/tools/node/toolUtils.ts | 106 ++++++++++++-- .../extension/tools/node/viewImageTool.tsx | 4 +- 7 files changed, 235 insertions(+), 75 deletions(-) diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index b882e3932019c..da68766d1fc19 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { t } from '@vscode/l10n'; -import { realpath } from 'fs/promises'; import { homedir } from 'os'; -import * as path from 'path'; import type { LanguageModelChat, PreparedToolInvocation } from 'vscode'; import { ToolName } from '../common/toolNames'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; @@ -37,6 +35,7 @@ import { ServicesAccessor } from '../../../util/vs/platform/instantiation/common import { EndOfLine, Position, Range, TextEdit } from '../../../vscodeTypes'; import { IBuildPromptContext } from '../../prompt/common/intents'; import { formatUriForFileWidget } from '../common/toolUtils'; +import { resolveRealPathForNonexistent } from './toolUtils'; // Simplified Hunk type for the patch interface Hunk { @@ -803,46 +802,6 @@ export const enum ConfirmationCheckResult { OutsideWorkspace, } -/** - * Resolves the real path of `fsPath`, walking up the parent chain when the path - * (or its ancestors) does not yet exist on disk. This ensures that a symlink at - * any ancestor. - */ -async function resolveRealPathForNonexistent(fsPath: string): Promise { - try { - return await realpath(fsPath); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - } - - const tail: string[] = [path.basename(fsPath)]; - let current = path.dirname(fsPath); - while (true) { - const parent = path.dirname(current); - if (parent === current) { - // Reached the filesystem root without finding an existing ancestor. - // Don't attempt to resolve the root itself — on Windows, realpath('\\') - // normalizes to a drive letter (e.g. 'C:\\'), which would otherwise look - // like a redirect even though no symlink was involved. - return fsPath; - } - try { - const resolved = await realpath(current); - return path.join(resolved, ...tail); - } catch (e) { - const code = (e as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - throw e; - } - } - tail.unshift(path.basename(current)); - current = parent; - } -} - - /** * Returns a function that returns whether a URI is approved for editing without * further user confirmation. @@ -939,11 +898,11 @@ export function makeUriConfirmationChecker(configuration: IConfigurationService, const toCheck = [normalizePath(uri)]; if (uri.scheme === Schemas.file) { try { - const linked = await resolveRealPathForNonexistent(uri.fsPath); - assertPathIsSafe(linked); + const linked = await resolveRealPathForNonexistent(uri); + assertPathIsSafe(linked.fsPath); - if (linked !== uri.fsPath) { - toCheck.push(URI.file(linked)); + if (!extUriBiasedIgnorePathCase.isEqual(linked, uri)) { + toCheck.push(linked); } } catch (e) { if ((e as NodeJS.ErrnoException).code === 'EPERM') { diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 359d5995dcdec..fbeeb923f055b 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -220,11 +220,11 @@ export class ReadFileTool implements ICopilotTool { } // Check if file is external (outside workspace, not open in editor, etc.) - const isExternal = await this.instantiationService.invokeFunction( + const { needsConfirmation, realPath } = await this.instantiationService.invokeFunction( accessor => isFileExternalAndNeedsConfirmation(accessor, uri!, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) ); - if (isExternal) { + if (needsConfirmation) { // Still check content exclusion (copilot ignore) await this.instantiationService.invokeFunction( accessor => assertFileNotContentExcluded(accessor, uri!) @@ -232,7 +232,13 @@ export class ReadFileTool implements ICopilotTool { const folderUri = dirname(uri); - const message = this.workspaceService.getWorkspaceFolders().length === 1 ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`) : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`); + const message = realPath + ? this.workspaceService.getWorkspaceFolders().length === 1 + ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current folder.`) + : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current workspace.`) + : this.workspaceService.getWorkspaceFolders().length === 1 + ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`) + : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`); // Return confirmation request for external file // The folder-based "allow this session" option is provided by the core confirmation contribution diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index 6cecf0efc9acb..583d6317d137d 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -278,9 +278,9 @@ class SearchSubagentTool implements ICopilotTool { // (e.g. file missing), keep the original line with the error. let isExternal = false; try { - isExternal = await this.instantiationService.invokeFunction(accessor => + ({ needsConfirmation: isExternal } = await this.instantiationService.invokeFunction(accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._inputContext, { readOnly: true, workingDirectory }) - ); + )); } catch { // isFileExternalAndNeedsConfirmation throws for nonexistent files; // treat that as "not external" so the original line is preserved. diff --git a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts index 724da1558d131..31abbca82a637 100644 --- a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts @@ -273,7 +273,7 @@ suite('SearchSubagentTool', () => { const { tool } = makeToolInstance(false, 4, { invokeFunction: sequencedInvokeFunction( () => { throw new Error('outside workspace'); }, - true, + { needsConfirmation: true, realPath: undefined }, ), }); @@ -287,7 +287,7 @@ suite('SearchSubagentTool', () => { const { tool } = makeToolInstance(false, 4, { invokeFunction: sequencedInvokeFunction( undefined, - false, + { needsConfirmation: false, realPath: undefined }, ), openTextDocument: async () => { throw new Error('file not found'); }, }); diff --git a/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts b/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts index 098b8c67ccd9c..b25e9edb6baa2 100644 --- a/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts @@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { afterAll, beforeAll, beforeEach, describe, expect, it, suite, test } from 'vitest'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, suite, test } from 'vitest'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService'; @@ -18,6 +21,7 @@ import { WorkingDirectory } from '../../../../platform/workspace/common/workingD import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { ResourceSet } from '../../../../util/vs/base/common/map'; import { posix } from '../../../../util/vs/base/common/path'; +import { isWindows } from '../../../../util/vs/base/common/platform'; import { URI } from '../../../../util/vs/base/common/uri'; import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; @@ -25,7 +29,7 @@ import { ChatVariablesCollection, CustomizationsIndexId } from '../../../prompt/ import { IBuildPromptContext } from '../../../prompt/common/intents'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; import { encodeUrlHostname } from '../../common/toolUtils'; -import { assertFileOkForTool, inputGlobToPattern, isDirExternalAndNeedsConfirmation, isFileExternalAndNeedsConfirmation } from '../toolUtils'; +import { assertFileOkForTool, inputGlobToPattern, isDirExternalAndNeedsConfirmation, isExternalSymlinkedFile, isFileExternalAndNeedsConfirmation } from '../toolUtils'; class TestIgnoreService extends NullIgnoreService { private readonly _ignoredUris = new Set(); @@ -159,7 +163,7 @@ suite('toolUtils - additionalReadAccessPaths', () => { describe('isFileExternalAndNeedsConfirmation', () => { test('workspace file does not need confirmation', async () => { - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('external file that does not exist throws', async () => { @@ -174,17 +178,17 @@ suite('toolUtils - additionalReadAccessPaths', () => { test('non-existent workspace file does not need confirmation', async () => { // Non-existent files within the workspace should also not trigger confirmation - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('external file under additional paths with readOnly does not need confirmation', async () => { await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/file.ts'), true)).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/file.ts'), true)).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('nested file under additional paths with readOnly does not need confirmation', async () => { await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/deep/nested/file.ts'), true)).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/deep/nested/file.ts'), true)).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('external file under additional paths without readOnly throws when file does not exist', async () => { @@ -256,7 +260,7 @@ suite('toolUtils - additionalReadAccessPaths', () => { }); test('isFileExternalAndNeedsConfirmation: file within workingDirectory is not external', async () => { - expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toBe(false); + expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('isFileExternalAndNeedsConfirmation: workspace file is external when workingDirectory is set', async () => { @@ -383,7 +387,7 @@ suite('toolUtils - external file existence', () => { test('external file that exists needs confirmation', async () => { // Mock an external file that actually exists mockFs.mockFile(URI.file('/external/existing-file.ts'), 'content'); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/existing-file.ts'))).toBe(true); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/existing-file.ts'))).toEqual({ needsConfirmation: true, realPath: undefined }); }); test('external file that does not exist throws', async () => { @@ -395,12 +399,117 @@ suite('toolUtils - external file existence', () => { test('workspace file does not need confirmation even if it exists', async () => { // Mock a workspace file mockFs.mockFile(URI.file('/workspace/file.ts'), 'content'); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('workspace file does not need confirmation even if it does not exist', async () => { // Non-existent workspace file - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); + }); +}); + +describe.skipIf(isWindows)('isExternalSymlinkedFile', () => { + let temporaryDirectory: string; + let workspaceDirectory: string; + let externalDirectory: string; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync(path.join(tmpdir(), 'toolutils-symlink-')); + workspaceDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'workspace-'))); + externalDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'external-'))); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + function getFolder(uri: URI): URI | undefined { + const workspaceUri = URI.file(workspaceDirectory); + return uri.fsPath === workspaceDirectory || uri.fsPath.startsWith(`${workspaceDirectory}${path.sep}`) ? workspaceUri : undefined; + } + + async function invokeIsFileExternal(uri: URI) { + const services = createExtensionUnitTestingServices(); + services.define(IWorkspaceService, new SyncDescriptor( + TestWorkspaceService, + [[URI.file(workspaceDirectory)], []] + )); + const accessor = services.createTestingAccessor(); + try { + return await accessor.get(IInstantiationService).invokeFunction(acc => isFileExternalAndNeedsConfirmation(acc, uri)); + } finally { + accessor.dispose(); + } + } + + test('returns true when the file symlink points outside the workspace', async () => { + const externalFile = path.join(externalDirectory, 'external.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(externalFile, 'content'); + fs.symlinkSync(externalFile, symlinkedFile); + + await expect(isExternalSymlinkedFile(URI.file(symlinkedFile), getFolder)).resolves.toBe(true); + }); + + test('returns the real path when a workspace symlink points outside the workspace', async () => { + const externalFile = path.join(externalDirectory, 'external.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(externalFile, 'content'); + fs.symlinkSync(externalFile, symlinkedFile); + + await expect(invokeIsFileExternal(URI.file(symlinkedFile))).resolves.toEqual({ + needsConfirmation: true, + realPath: URI.file(externalFile), + }); + }); + + test('returns true when a parent directory symlink points outside the workspace', async () => { + const externalFile = path.join(externalDirectory, 'external.txt'); + const symlinkedDirectory = path.join(workspaceDirectory, 'linked'); + fs.writeFileSync(externalFile, 'content'); + fs.symlinkSync(externalDirectory, symlinkedDirectory, 'dir'); + + await expect(isExternalSymlinkedFile(URI.file(path.join(symlinkedDirectory, 'external.txt')), getFolder)).resolves.toBe(true); + }); + + test('returns true for a nonexistent file under a parent directory symlink that points outside the workspace', async () => { + const symlinkedDirectory = path.join(workspaceDirectory, 'linked'); + fs.symlinkSync(externalDirectory, symlinkedDirectory, 'dir'); + + await expect(isExternalSymlinkedFile(URI.file(path.join(symlinkedDirectory, 'missing.txt')), getFolder)).resolves.toBe(true); + }); + + test('returns false when the symlink target is inside the workspace', async () => { + const targetFile = path.join(workspaceDirectory, 'target.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(targetFile, 'content'); + fs.symlinkSync(targetFile, symlinkedFile); + + await expect(isExternalSymlinkedFile(URI.file(symlinkedFile), getFolder)).resolves.toBe(false); + }); + + test('returns false when the file is not a symlink', async () => { + const file = path.join(workspaceDirectory, 'file.txt'); + fs.writeFileSync(file, 'content'); + + await expect(isExternalSymlinkedFile(URI.file(file), getFolder)).resolves.toBe(false); + }); + + test('returns false when the workspace folder itself is symlinked', async () => { + const symlinkedWorkspace = path.join(temporaryDirectory, 'workspace-link'); + const workspaceFile = path.join(workspaceDirectory, 'file.txt'); + fs.writeFileSync(workspaceFile, 'content'); + fs.symlinkSync(workspaceDirectory, symlinkedWorkspace, 'dir'); + const symlinkedWorkspaceUri = URI.file(symlinkedWorkspace); + + await expect(isExternalSymlinkedFile( + URI.file(path.join(symlinkedWorkspace, 'file.txt')), + uri => uri.fsPath.startsWith(`${symlinkedWorkspace}${path.sep}`) ? symlinkedWorkspaceUri : undefined + )).resolves.toBe(false); + }); + + test('returns false when the file does not exist', async () => { + await expect(isExternalSymlinkedFile(URI.file(path.join(workspaceDirectory, 'missing.txt')), getFolder)).resolves.toBe(false); }); }); diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index e799cc8ab3808..3a902b6180fe1 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptPiece } from '@vscode/prompt-tsx'; +import { lstat, realpath } from 'fs/promises'; +import * as path from 'path'; import type * as vscode from 'vscode'; import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; @@ -281,7 +283,12 @@ export async function assertFileNotContentExcluded(accessor: ServicesAccessor, u } } -export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): Promise { +export interface FileExternalConfirmationResult { + readonly needsConfirmation: boolean; + readonly realPath: URI | undefined; +} + +export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): Promise { const workspaceService = accessor.get(IWorkspaceService); const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); const customInstructionsService = accessor.get(ICustomInstructionsService); @@ -295,28 +302,29 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces const workingDir = new WorkingDirectory(options?.workingDirectory, workspaceService); if (workingDir.getFolder(normalizedUri)) { - return false; + const realPath = await getExternalSymlinkRealPath(normalizedUri, uri => workingDir.getFolder(uri)); + return { needsConfirmation: realPath !== undefined, realPath }; } if (options?.readOnly && isUriUnderAdditionalReadAccessPaths(normalizedUri, configurationService)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (uri.scheme === Schemas.untitled || uri.scheme === 'vscode-chat-response-resource') { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (await isExternalInstructionsFile(normalizedUri, customInstructionsService, buildPromptContext)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (diskSessionResources.isSessionResourceUri(normalizedUri)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (sessionTranscriptService.isTranscriptUri(normalizedUri)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri))) { - return false; + return { needsConfirmation: false, realPath: undefined }; } // If the file doesn't exist, throw immediately rather than showing a confusing "external file" @@ -326,7 +334,85 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces throw new Error(`File ${normalizedUri.fsPath} does not exist`); } - return true; + return { needsConfirmation: true, realPath: undefined }; +} + +/** + * Checks whether a symlinked file resolves outside the workspace. + */ +export async function isExternalSymlinkedFile(uri: URI, getFolder: (uri: URI) => URI | undefined): Promise { + return (await getExternalSymlinkRealPath(uri, getFolder)) !== undefined; +} + +async function getExternalSymlinkRealPath(uri: URI, getFolder: (uri: URI) => URI | undefined): Promise { + if (uri.scheme !== Schemas.file) { + return undefined; + } + + const workspaceFolder = getFolder(uri); + if (!workspaceFolder || workspaceFolder.scheme !== Schemas.file) { + return undefined; + } + + let current = uri.fsPath; + while (!isEqual(normalizePath(URI.file(current)), normalizePath(workspaceFolder))) { + try { + if ((await lstat(current)).isSymbolicLink()) { + const resolvedUri = normalizePath(await resolveRealPathForNonexistent(uri, workspaceFolder)); + const resolvedWorkspaceFolder = normalizePath(URI.file(await realpath(workspaceFolder.fsPath))); + return extUriBiasedIgnorePathCase.isEqualOrParent(resolvedUri, resolvedWorkspaceFolder) ? undefined : resolvedUri; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } + + return undefined; +} + +/** + * Resolves a path through its nearest existing ancestor without walking above `stopAt`. + */ +export async function resolveRealPathForNonexistent(resource: URI, stopAt?: URI): Promise { + try { + return URI.file(await realpath(resource.fsPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const tail = [path.basename(resource.fsPath)]; + let current = path.dirname(resource.fsPath); + while (true) { + if (stopAt && isEqual(normalizePath(URI.file(current)), normalizePath(stopAt))) { + return URI.file(path.join(await realpath(stopAt.fsPath), ...tail)); + } + + try { + return URI.file(path.join(await realpath(current), ...tail)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } + } + + const parent = path.dirname(current); + if (parent === current) { + return resource; + } + tail.unshift(path.basename(current)); + current = parent; + } } export function isDirExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): boolean { diff --git a/extensions/copilot/src/extension/tools/node/viewImageTool.tsx b/extensions/copilot/src/extension/tools/node/viewImageTool.tsx index a807903f6113f..fbb9c47372a29 100644 --- a/extensions/copilot/src/extension/tools/node/viewImageTool.tsx +++ b/extensions/copilot/src/extension/tools/node/viewImageTool.tsx @@ -63,11 +63,11 @@ export class ViewImageTool implements ICopilotTool { const uri = resolveToolInputPath(options.input.filePath, this.promptPathRepresentationService); this.assertImageFile(uri); - const isExternal = await this.instantiationService.invokeFunction( + const { needsConfirmation } = await this.instantiationService.invokeFunction( accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) ); - if (isExternal) { + if (needsConfirmation) { await this.instantiationService.invokeFunction( accessor => assertFileNotContentExcluded(accessor, uri) ); From a0226bd7badcd2e5d6957e41fb06ea97c35ce8d9 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 20 Aug 2026 16:49:47 +0200 Subject: [PATCH 2/2] fix windows tests --- .../copilot/src/extension/tools/node/toolUtils.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index 3a902b6180fe1..ebcf905c99b11 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -397,6 +397,12 @@ export async function resolveRealPathForNonexistent(resource: URI, stopAt?: URI) return URI.file(path.join(await realpath(stopAt.fsPath), ...tail)); } + const parent = path.dirname(current); + if (parent === current) { + // On Windows, resolving `\` adds the current drive and can make an unchanged path appear redirected. + return resource; + } + try { return URI.file(path.join(await realpath(current), ...tail)); } catch (error) { @@ -406,10 +412,6 @@ export async function resolveRealPathForNonexistent(resource: URI, stopAt?: URI) } } - const parent = path.dirname(current); - if (parent === current) { - return resource; - } tail.unshift(path.basename(current)); current = parent; }