Skip to content

Commit 680caf2

Browse files
authored
warn when reading linked files (#331799)
* warn when reading symlinked files * fix windows tests
1 parent e251532 commit 680caf2

7 files changed

Lines changed: 237 additions & 75 deletions

File tree

extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx

Lines changed: 5 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { t } from '@vscode/l10n';
7-
import { realpath } from 'fs/promises';
87
import { homedir } from 'os';
9-
import * as path from 'path';
108
import type { LanguageModelChat, PreparedToolInvocation } from 'vscode';
119
import { ToolName } from '../common/toolNames';
1210
import { IConfigurationService } from '../../../platform/configuration/common/configurationService';
@@ -37,6 +35,7 @@ import { ServicesAccessor } from '../../../util/vs/platform/instantiation/common
3735
import { EndOfLine, Position, Range, TextEdit } from '../../../vscodeTypes';
3836
import { IBuildPromptContext } from '../../prompt/common/intents';
3937
import { formatUriForFileWidget } from '../common/toolUtils';
38+
import { resolveRealPathForNonexistent } from './toolUtils';
4039

4140
// Simplified Hunk type for the patch
4241
interface Hunk {
@@ -803,46 +802,6 @@ export const enum ConfirmationCheckResult {
803802
OutsideWorkspace,
804803
}
805804

806-
/**
807-
* Resolves the real path of `fsPath`, walking up the parent chain when the path
808-
* (or its ancestors) does not yet exist on disk. This ensures that a symlink at
809-
* any ancestor.
810-
*/
811-
async function resolveRealPathForNonexistent(fsPath: string): Promise<string> {
812-
try {
813-
return await realpath(fsPath);
814-
} catch (e) {
815-
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
816-
throw e;
817-
}
818-
}
819-
820-
const tail: string[] = [path.basename(fsPath)];
821-
let current = path.dirname(fsPath);
822-
while (true) {
823-
const parent = path.dirname(current);
824-
if (parent === current) {
825-
// Reached the filesystem root without finding an existing ancestor.
826-
// Don't attempt to resolve the root itself — on Windows, realpath('\\')
827-
// normalizes to a drive letter (e.g. 'C:\\'), which would otherwise look
828-
// like a redirect even though no symlink was involved.
829-
return fsPath;
830-
}
831-
try {
832-
const resolved = await realpath(current);
833-
return path.join(resolved, ...tail);
834-
} catch (e) {
835-
const code = (e as NodeJS.ErrnoException).code;
836-
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
837-
throw e;
838-
}
839-
}
840-
tail.unshift(path.basename(current));
841-
current = parent;
842-
}
843-
}
844-
845-
846805
/**
847806
* Returns a function that returns whether a URI is approved for editing without
848807
* further user confirmation.
@@ -939,11 +898,11 @@ export function makeUriConfirmationChecker(configuration: IConfigurationService,
939898
const toCheck = [normalizePath(uri)];
940899
if (uri.scheme === Schemas.file) {
941900
try {
942-
const linked = await resolveRealPathForNonexistent(uri.fsPath);
943-
assertPathIsSafe(linked);
901+
const linked = await resolveRealPathForNonexistent(uri);
902+
assertPathIsSafe(linked.fsPath);
944903

945-
if (linked !== uri.fsPath) {
946-
toCheck.push(URI.file(linked));
904+
if (!extUriBiasedIgnorePathCase.isEqual(linked, uri)) {
905+
toCheck.push(linked);
947906
}
948907
} catch (e) {
949908
if ((e as NodeJS.ErrnoException).code === 'EPERM') {

extensions/copilot/src/extension/tools/node/readFileTool.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,19 +220,25 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> {
220220
}
221221

222222
// Check if file is external (outside workspace, not open in editor, etc.)
223-
const isExternal = await this.instantiationService.invokeFunction(
223+
const { needsConfirmation, realPath } = await this.instantiationService.invokeFunction(
224224
accessor => isFileExternalAndNeedsConfirmation(accessor, uri!, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory })
225225
);
226226

227-
if (isExternal) {
227+
if (needsConfirmation) {
228228
// Still check content exclusion (copilot ignore)
229229
await this.instantiationService.invokeFunction(
230230
accessor => assertFileNotContentExcluded(accessor, uri!)
231231
);
232232

233233
const folderUri = dirname(uri);
234234

235-
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)}.`);
235+
const message = realPath
236+
? this.workspaceService.getWorkspaceFolders().length === 1
237+
? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current folder.`)
238+
: new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current workspace.`)
239+
: this.workspaceService.getWorkspaceFolders().length === 1
240+
? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`)
241+
: new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`);
236242

237243
// Return confirmation request for external file
238244
// The folder-based "allow this session" option is provided by the core confirmation contribution

extensions/copilot/src/extension/tools/node/searchSubagentTool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,9 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
278278
// (e.g. file missing), keep the original line with the error.
279279
let isExternal = false;
280280
try {
281-
isExternal = await this.instantiationService.invokeFunction(accessor =>
281+
({ needsConfirmation: isExternal } = await this.instantiationService.invokeFunction(accessor =>
282282
isFileExternalAndNeedsConfirmation(accessor, uri, this._inputContext, { readOnly: true, workingDirectory })
283-
);
283+
));
284284
} catch {
285285
// isFileExternalAndNeedsConfirmation throws for nonexistent files;
286286
// treat that as "not external" so the original line is preserved.

extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ suite('SearchSubagentTool', () => {
273273
const { tool } = makeToolInstance(false, 4, {
274274
invokeFunction: sequencedInvokeFunction(
275275
() => { throw new Error('outside workspace'); },
276-
true,
276+
{ needsConfirmation: true, realPath: undefined },
277277
),
278278
});
279279

@@ -287,7 +287,7 @@ suite('SearchSubagentTool', () => {
287287
const { tool } = makeToolInstance(false, 4, {
288288
invokeFunction: sequencedInvokeFunction(
289289
undefined,
290-
false,
290+
{ needsConfirmation: false, realPath: undefined },
291291
),
292292
openTextDocument: async () => { throw new Error('file not found'); },
293293
});

extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { afterAll, beforeAll, beforeEach, describe, expect, it, suite, test } from 'vitest';
6+
import * as fs from 'fs';
7+
import { tmpdir } from 'os';
8+
import * as path from 'path';
9+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, suite, test } from 'vitest';
710
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
811
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
912
import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService';
@@ -18,14 +21,15 @@ import { WorkingDirectory } from '../../../../platform/workspace/common/workingD
1821
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
1922
import { ResourceSet } from '../../../../util/vs/base/common/map';
2023
import { posix } from '../../../../util/vs/base/common/path';
24+
import { isWindows } from '../../../../util/vs/base/common/platform';
2125
import { URI } from '../../../../util/vs/base/common/uri';
2226
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
2327
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
2428
import { ChatVariablesCollection, CustomizationsIndexId } from '../../../prompt/common/chatVariablesCollection';
2529
import { IBuildPromptContext } from '../../../prompt/common/intents';
2630
import { createExtensionUnitTestingServices } from '../../../test/node/services';
2731
import { encodeUrlHostname } from '../../common/toolUtils';
28-
import { assertFileOkForTool, inputGlobToPattern, isDirExternalAndNeedsConfirmation, isFileExternalAndNeedsConfirmation } from '../toolUtils';
32+
import { assertFileOkForTool, inputGlobToPattern, isDirExternalAndNeedsConfirmation, isExternalSymlinkedFile, isFileExternalAndNeedsConfirmation } from '../toolUtils';
2933

3034
class TestIgnoreService extends NullIgnoreService {
3135
private readonly _ignoredUris = new Set<string>();
@@ -159,7 +163,7 @@ suite('toolUtils - additionalReadAccessPaths', () => {
159163

160164
describe('isFileExternalAndNeedsConfirmation', () => {
161165
test('workspace file does not need confirmation', async () => {
162-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toBe(false);
166+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined });
163167
});
164168

165169
test('external file that does not exist throws', async () => {
@@ -174,17 +178,17 @@ suite('toolUtils - additionalReadAccessPaths', () => {
174178

175179
test('non-existent workspace file does not need confirmation', async () => {
176180
// Non-existent files within the workspace should also not trigger confirmation
177-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toBe(false);
181+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toEqual({ needsConfirmation: false, realPath: undefined });
178182
});
179183

180184
test('external file under additional paths with readOnly does not need confirmation', async () => {
181185
await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']);
182-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/file.ts'), true)).toBe(false);
186+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/file.ts'), true)).toEqual({ needsConfirmation: false, realPath: undefined });
183187
});
184188

185189
test('nested file under additional paths with readOnly does not need confirmation', async () => {
186190
await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']);
187-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/deep/nested/file.ts'), true)).toBe(false);
191+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/deep/nested/file.ts'), true)).toEqual({ needsConfirmation: false, realPath: undefined });
188192
});
189193

190194
test('external file under additional paths without readOnly throws when file does not exist', async () => {
@@ -256,7 +260,7 @@ suite('toolUtils - additionalReadAccessPaths', () => {
256260
});
257261

258262
test('isFileExternalAndNeedsConfirmation: file within workingDirectory is not external', async () => {
259-
expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toBe(false);
263+
expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined });
260264
});
261265

262266
test('isFileExternalAndNeedsConfirmation: workspace file is external when workingDirectory is set', async () => {
@@ -383,7 +387,7 @@ suite('toolUtils - external file existence', () => {
383387
test('external file that exists needs confirmation', async () => {
384388
// Mock an external file that actually exists
385389
mockFs.mockFile(URI.file('/external/existing-file.ts'), 'content');
386-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/existing-file.ts'))).toBe(true);
390+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/existing-file.ts'))).toEqual({ needsConfirmation: true, realPath: undefined });
387391
});
388392

389393
test('external file that does not exist throws', async () => {
@@ -395,12 +399,117 @@ suite('toolUtils - external file existence', () => {
395399
test('workspace file does not need confirmation even if it exists', async () => {
396400
// Mock a workspace file
397401
mockFs.mockFile(URI.file('/workspace/file.ts'), 'content');
398-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toBe(false);
402+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined });
399403
});
400404

401405
test('workspace file does not need confirmation even if it does not exist', async () => {
402406
// Non-existent workspace file
403-
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toBe(false);
407+
expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toEqual({ needsConfirmation: false, realPath: undefined });
408+
});
409+
});
410+
411+
describe.skipIf(isWindows)('isExternalSymlinkedFile', () => {
412+
let temporaryDirectory: string;
413+
let workspaceDirectory: string;
414+
let externalDirectory: string;
415+
416+
beforeEach(() => {
417+
temporaryDirectory = fs.mkdtempSync(path.join(tmpdir(), 'toolutils-symlink-'));
418+
workspaceDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'workspace-')));
419+
externalDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'external-')));
420+
});
421+
422+
afterEach(() => {
423+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
424+
});
425+
426+
function getFolder(uri: URI): URI | undefined {
427+
const workspaceUri = URI.file(workspaceDirectory);
428+
return uri.fsPath === workspaceDirectory || uri.fsPath.startsWith(`${workspaceDirectory}${path.sep}`) ? workspaceUri : undefined;
429+
}
430+
431+
async function invokeIsFileExternal(uri: URI) {
432+
const services = createExtensionUnitTestingServices();
433+
services.define(IWorkspaceService, new SyncDescriptor(
434+
TestWorkspaceService,
435+
[[URI.file(workspaceDirectory)], []]
436+
));
437+
const accessor = services.createTestingAccessor();
438+
try {
439+
return await accessor.get(IInstantiationService).invokeFunction(acc => isFileExternalAndNeedsConfirmation(acc, uri));
440+
} finally {
441+
accessor.dispose();
442+
}
443+
}
444+
445+
test('returns true when the file symlink points outside the workspace', async () => {
446+
const externalFile = path.join(externalDirectory, 'external.txt');
447+
const symlinkedFile = path.join(workspaceDirectory, 'linked.txt');
448+
fs.writeFileSync(externalFile, 'content');
449+
fs.symlinkSync(externalFile, symlinkedFile);
450+
451+
await expect(isExternalSymlinkedFile(URI.file(symlinkedFile), getFolder)).resolves.toBe(true);
452+
});
453+
454+
test('returns the real path when a workspace symlink points outside the workspace', async () => {
455+
const externalFile = path.join(externalDirectory, 'external.txt');
456+
const symlinkedFile = path.join(workspaceDirectory, 'linked.txt');
457+
fs.writeFileSync(externalFile, 'content');
458+
fs.symlinkSync(externalFile, symlinkedFile);
459+
460+
await expect(invokeIsFileExternal(URI.file(symlinkedFile))).resolves.toEqual({
461+
needsConfirmation: true,
462+
realPath: URI.file(externalFile),
463+
});
464+
});
465+
466+
test('returns true when a parent directory symlink points outside the workspace', async () => {
467+
const externalFile = path.join(externalDirectory, 'external.txt');
468+
const symlinkedDirectory = path.join(workspaceDirectory, 'linked');
469+
fs.writeFileSync(externalFile, 'content');
470+
fs.symlinkSync(externalDirectory, symlinkedDirectory, 'dir');
471+
472+
await expect(isExternalSymlinkedFile(URI.file(path.join(symlinkedDirectory, 'external.txt')), getFolder)).resolves.toBe(true);
473+
});
474+
475+
test('returns true for a nonexistent file under a parent directory symlink that points outside the workspace', async () => {
476+
const symlinkedDirectory = path.join(workspaceDirectory, 'linked');
477+
fs.symlinkSync(externalDirectory, symlinkedDirectory, 'dir');
478+
479+
await expect(isExternalSymlinkedFile(URI.file(path.join(symlinkedDirectory, 'missing.txt')), getFolder)).resolves.toBe(true);
480+
});
481+
482+
test('returns false when the symlink target is inside the workspace', async () => {
483+
const targetFile = path.join(workspaceDirectory, 'target.txt');
484+
const symlinkedFile = path.join(workspaceDirectory, 'linked.txt');
485+
fs.writeFileSync(targetFile, 'content');
486+
fs.symlinkSync(targetFile, symlinkedFile);
487+
488+
await expect(isExternalSymlinkedFile(URI.file(symlinkedFile), getFolder)).resolves.toBe(false);
489+
});
490+
491+
test('returns false when the file is not a symlink', async () => {
492+
const file = path.join(workspaceDirectory, 'file.txt');
493+
fs.writeFileSync(file, 'content');
494+
495+
await expect(isExternalSymlinkedFile(URI.file(file), getFolder)).resolves.toBe(false);
496+
});
497+
498+
test('returns false when the workspace folder itself is symlinked', async () => {
499+
const symlinkedWorkspace = path.join(temporaryDirectory, 'workspace-link');
500+
const workspaceFile = path.join(workspaceDirectory, 'file.txt');
501+
fs.writeFileSync(workspaceFile, 'content');
502+
fs.symlinkSync(workspaceDirectory, symlinkedWorkspace, 'dir');
503+
const symlinkedWorkspaceUri = URI.file(symlinkedWorkspace);
504+
505+
await expect(isExternalSymlinkedFile(
506+
URI.file(path.join(symlinkedWorkspace, 'file.txt')),
507+
uri => uri.fsPath.startsWith(`${symlinkedWorkspace}${path.sep}`) ? symlinkedWorkspaceUri : undefined
508+
)).resolves.toBe(false);
509+
});
510+
511+
test('returns false when the file does not exist', async () => {
512+
await expect(isExternalSymlinkedFile(URI.file(path.join(workspaceDirectory, 'missing.txt')), getFolder)).resolves.toBe(false);
404513
});
405514
});
406515

0 commit comments

Comments
 (0)