Skip to content

Commit e428f03

Browse files
authored
fix: fetch tool should not warn when reading files inside the workspace (microsoft#303789)
1 parent c021375 commit e428f03

2 files changed

Lines changed: 134 additions & 8 deletions

File tree

src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { URI } from '../../../../../base/common/uri.js';
1313
import { localize } from '../../../../../nls.js';
1414
import { IFileService } from '../../../../../platform/files/common/files.js';
1515
import { IWebContentExtractorService, WebContentExtractResult } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js';
16+
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
1617
import { detectEncodingFromBuffer } from '../../../../services/textfile/common/encoding.js';
1718
import { ITrustedDomainService } from '../../../url/browser/trustedDomainService.js';
1819
import { IChatService } from '../../common/chatService/chatService.js';
@@ -56,6 +57,7 @@ export class FetchWebPageTool implements IToolImpl {
5657
@IFileService private readonly _fileService: IFileService,
5758
@ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService,
5859
@IChatService private readonly _chatService: IChatService,
60+
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
5961
) { }
6062

6163
async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise<IToolResult> {
@@ -170,15 +172,23 @@ export class FetchWebPageTool implements IToolImpl {
170172
}
171173

172174
const invalid = [...Array.from(invalidUris), ...additionalInvalidUrls];
173-
const urlsNeedingConfirmation = new ResourceSet([...webUris.values(), ...validFileUris]);
175+
// All valid URIs (web + file) for display in messages
176+
const allFetchedUris = new ResourceSet([...webUris.values(), ...validFileUris]);
177+
// File URIs that are inside the workspace don't need confirmation — they're already accessible
178+
// and don't carry the web content risks (prompt injection, malicious redirects).
179+
// File URIs outside the workspace are treated like web URIs and require confirmation.
180+
const fileUrisOutsideWorkspace = validFileUris.filter(
181+
uri => !this._workspaceContextService.getWorkspaceFolder(uri)
182+
);
183+
const urlsNeedingConfirmation = new ResourceSet([...webUris.values(), ...fileUrisOutsideWorkspace]);
174184

175185
const pastTenseMessage = invalid.length
176186
? invalid.length > 1
177187
// If there are multiple invalid URLs, show them all
178188
? new MarkdownString(
179189
localize(
180190
'fetchWebPage.pastTenseMessage.plural',
181-
'Fetched {0} resources, but the following were invalid URLs:\n\n{1}\n\n', urlsNeedingConfirmation.size, invalid.map(url => `- ${url}`).join('\n')
191+
'Fetched {0} resources, but the following were invalid URLs:\n\n{1}\n\n', allFetchedUris.size, invalid.map(url => `- ${url}`).join('\n')
182192
))
183193
// If there is only one invalid URL, show it
184194
: new MarkdownString(
@@ -190,11 +200,11 @@ export class FetchWebPageTool implements IToolImpl {
190200
: new MarkdownString();
191201

192202
const invocationMessage = new MarkdownString();
193-
if (urlsNeedingConfirmation.size > 1) {
194-
pastTenseMessage.appendMarkdown(localize('fetchWebPage.pastTenseMessageResult.plural', 'Fetched {0} resources', urlsNeedingConfirmation.size));
195-
invocationMessage.appendMarkdown(localize('fetchWebPage.invocationMessage.plural', 'Fetching {0} resources', urlsNeedingConfirmation.size));
196-
} else if (urlsNeedingConfirmation.size === 1) {
197-
const url = Iterable.first(urlsNeedingConfirmation)!.toString(true);
203+
if (allFetchedUris.size > 1) {
204+
pastTenseMessage.appendMarkdown(localize('fetchWebPage.pastTenseMessageResult.plural', 'Fetched {0} resources', allFetchedUris.size));
205+
invocationMessage.appendMarkdown(localize('fetchWebPage.invocationMessage.plural', 'Fetching {0} resources', allFetchedUris.size));
206+
} else if (allFetchedUris.size === 1) {
207+
const url = Iterable.first(allFetchedUris)!.toString(true);
198208
// If the URL is too long or it's a file url, show it as a link... otherwise, show it as plain text
199209
if (url.length > 400 || validFileUris.length === 1) {
200210
pastTenseMessage.appendMarkdown(localize({

src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import { ResourceMap } from '../../../../../../../base/common/map.js';
1111
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
1212
import { IFileContent, IReadFileOptions } from '../../../../../../../platform/files/common/files.js';
1313
import { IWebContentExtractorService, WebContentExtractResult } from '../../../../../../../platform/webContentExtractor/common/webContentExtractor.js';
14+
import { testWorkspace } from '../../../../../../../platform/workspace/test/common/testWorkspace.js';
1415
import { FetchWebPageTool } from '../../../../electron-browser/builtInTools/fetchPageTool.js';
15-
import { TestFileService } from '../../../../../../test/common/workbenchTestServices.js';
16+
import { TestContextService, TestFileService } from '../../../../../../test/common/workbenchTestServices.js';
1617
import { MockTrustedDomainService } from '../../../../../url/test/browser/mockTrustedDomainService.js';
1718
import { InternalFetchWebPageToolId } from '../../../../common/tools/builtinTools/tools.js';
1819
import { MockChatService } from '../../../common/chatService/mockChatService.js';
@@ -91,6 +92,7 @@ suite('FetchWebPageTool', () => {
9192
new ExtendedTestFileService(fileContentMap),
9293
new MockTrustedDomainService(),
9394
new MockChatService(),
95+
new TestContextService(),
9496
);
9597

9698
const testUrls = [
@@ -140,6 +142,7 @@ suite('FetchWebPageTool', () => {
140142
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
141143
new MockTrustedDomainService([]),
142144
new MockChatService(),
145+
new TestContextService(),
143146
);
144147

145148
// Test empty array
@@ -189,6 +192,7 @@ suite('FetchWebPageTool', () => {
189192
new ExtendedTestFileService(fileContentMap),
190193
new MockTrustedDomainService(),
191194
new MockChatService(),
195+
new TestContextService(),
192196
);
193197

194198
const preparation = await tool.prepareToolInvocation(
@@ -203,6 +207,100 @@ suite('FetchWebPageTool', () => {
203207
assert.ok(messageText.includes('invalid://invalid'), 'Should mention invalid URL');
204208
});
205209

210+
test('should not show confirmation dialog for file URIs inside the workspace', async () => {
211+
// Use a workspace rooted at /workspaceRoot
212+
const workspaceRoot = URI.file('/workspaceRoot');
213+
const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot));
214+
215+
const fileContentMap = new ResourceMap<string | VSBuffer>([
216+
[URI.file('/workspaceRoot/plan.md'), 'Plan content'],
217+
[URI.file('/workspaceRoot/subdir/notes.txt'), 'Notes content'],
218+
]);
219+
220+
const tool = new FetchWebPageTool(
221+
new TestWebContentExtractorService(new ResourceMap<string>()),
222+
new ExtendedTestFileService(fileContentMap),
223+
new MockTrustedDomainService([]),
224+
new MockChatService(),
225+
workspaceContextService,
226+
);
227+
228+
// File inside workspace - should NOT trigger confirmation
229+
const preparation = await tool.prepareToolInvocation(
230+
{ parameters: { urls: [URI.file('/workspaceRoot/plan.md').toString()] }, toolCallId: 'test-file-in-ws', chatSessionResource: undefined },
231+
CancellationToken.None
232+
);
233+
assert.ok(preparation, 'Should return prepared invocation');
234+
assert.strictEqual(preparation.confirmationMessages?.title, undefined, 'File inside workspace should not show confirmation dialog');
235+
assert.strictEqual(preparation.confirmationMessages?.confirmResults, false, 'File inside workspace should not require post-confirmation');
236+
});
237+
238+
test('should show confirmation dialog for file URIs outside the workspace', async () => {
239+
// Use a workspace rooted at /workspaceRoot
240+
const workspaceRoot = URI.file('/workspaceRoot');
241+
const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot));
242+
243+
const fileContentMap = new ResourceMap<string | VSBuffer>([
244+
[URI.file('/tmp/external-plan.md'), 'External plan content'],
245+
]);
246+
247+
const tool = new FetchWebPageTool(
248+
new TestWebContentExtractorService(new ResourceMap<string>()),
249+
new ExtendedTestFileService(fileContentMap),
250+
new MockTrustedDomainService([]),
251+
new MockChatService(),
252+
workspaceContextService,
253+
);
254+
255+
// File outside workspace - should still trigger confirmation
256+
const preparation = await tool.prepareToolInvocation(
257+
{ parameters: { urls: [URI.file('/tmp/external-plan.md').toString()] }, toolCallId: 'test-file-outside-ws', chatSessionResource: undefined },
258+
CancellationToken.None
259+
);
260+
assert.ok(preparation, 'Should return prepared invocation');
261+
assert.ok(preparation.confirmationMessages?.title, 'File outside workspace should show confirmation dialog');
262+
assert.strictEqual(preparation.confirmationMessages?.confirmResults, true, 'File outside workspace should require post-confirmation');
263+
});
264+
265+
test('workspace file mixed with untrusted web URI: only web URI triggers confirmation', async () => {
266+
const workspaceRoot = URI.file('/workspaceRoot');
267+
const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot));
268+
269+
const webContentMap = new ResourceMap<string>([
270+
[URI.parse('https://example.com'), 'Web content']
271+
]);
272+
const fileContentMap = new ResourceMap<string | VSBuffer>([
273+
[URI.file('/workspaceRoot/plan.md'), 'Plan content']
274+
]);
275+
276+
const tool = new FetchWebPageTool(
277+
new TestWebContentExtractorService(webContentMap),
278+
new ExtendedTestFileService(fileContentMap),
279+
new MockTrustedDomainService([]), // No trusted domains
280+
new MockChatService(),
281+
workspaceContextService,
282+
);
283+
284+
// Mix: one untrusted web URI + one workspace file URI
285+
const preparation = await tool.prepareToolInvocation(
286+
{
287+
parameters: { urls: ['https://example.com', URI.file('/workspaceRoot/plan.md').toString()] },
288+
toolCallId: 'test-mixed',
289+
chatSessionResource: undefined
290+
},
291+
CancellationToken.None
292+
);
293+
assert.ok(preparation, 'Should return prepared invocation');
294+
// Confirmation should only be for the web URI
295+
assert.ok(preparation.confirmationMessages?.title, 'Should show confirmation for untrusted web URI');
296+
// The confirmation message should mention only the web URI, not the workspace file
297+
const msgValue = typeof preparation.confirmationMessages?.message === 'string'
298+
? preparation.confirmationMessages.message
299+
: preparation.confirmationMessages?.message?.value ?? '';
300+
assert.ok(!msgValue.includes('/workspaceRoot/'), 'Confirmation message should not mention workspace file');
301+
assert.ok(msgValue.includes('example.com'), 'Confirmation message should mention web URI');
302+
});
303+
206304
test('should approve when all URLs were mentioned in chat', async () => {
207305
const webContentMap = new ResourceMap<string>([
208306
[URI.parse('https://valid.com'), 'Valid content']
@@ -227,6 +325,7 @@ suite('FetchWebPageTool', () => {
227325
};
228326
},
229327
}),
328+
new TestContextService(),
230329
);
231330

232331
const preparation1 = await tool.prepareToolInvocation(
@@ -261,6 +360,7 @@ suite('FetchWebPageTool', () => {
261360
new ExtendedTestFileService(fileContentMap),
262361
new MockTrustedDomainService(),
263362
new MockChatService(),
363+
new TestContextService(),
264364
);
265365

266366
const result = await tool.invoke(
@@ -309,6 +409,7 @@ suite('FetchWebPageTool', () => {
309409
new ExtendedTestFileService(fileContentMap),
310410
new MockTrustedDomainService(),
311411
new MockChatService(),
412+
new TestContextService(),
312413
);
313414

314415
const result = await tool.invoke(
@@ -350,6 +451,7 @@ suite('FetchWebPageTool', () => {
350451
new ExtendedTestFileService(fileContentMap),
351452
new MockTrustedDomainService(),
352453
new MockChatService(),
454+
new TestContextService(),
353455
);
354456

355457
const result = await tool.invoke(
@@ -397,6 +499,7 @@ suite('FetchWebPageTool', () => {
397499
new ExtendedTestFileService(fileContentMap),
398500
new MockTrustedDomainService(),
399501
new MockChatService(),
502+
new TestContextService(),
400503
);
401504

402505
const result = await tool.invoke(
@@ -463,6 +566,7 @@ suite('FetchWebPageTool', () => {
463566
new ExtendedTestFileService(fileContentMap),
464567
new MockTrustedDomainService(),
465568
new MockChatService(),
569+
new TestContextService(),
466570
);
467571

468572
const result = await tool.invoke(
@@ -503,6 +607,7 @@ suite('FetchWebPageTool', () => {
503607
new ExtendedTestFileService(fileContentMap),
504608
new MockTrustedDomainService(),
505609
new MockChatService(),
610+
new TestContextService(),
506611
);
507612

508613
const result = await tool.invoke(
@@ -547,6 +652,7 @@ suite('FetchWebPageTool', () => {
547652
new ExtendedTestFileService(fileContentMap),
548653
new MockTrustedDomainService(),
549654
new MockChatService(),
655+
new TestContextService(),
550656
);
551657

552658
const testUrls = [
@@ -606,6 +712,7 @@ suite('FetchWebPageTool', () => {
606712
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
607713
new MockTrustedDomainService([]),
608714
new MockChatService(),
715+
new TestContextService(),
609716
);
610717

611718
const testUrls = [
@@ -642,6 +749,7 @@ suite('FetchWebPageTool', () => {
642749
new ExtendedTestFileService(fileContentMap),
643750
new MockTrustedDomainService(),
644751
new MockChatService(),
752+
new TestContextService(),
645753
);
646754

647755
const testUrls = [
@@ -684,6 +792,7 @@ suite('FetchWebPageTool', () => {
684792
new ExtendedTestFileService(fileContentMap),
685793
new MockTrustedDomainService(),
686794
new MockChatService(),
795+
new TestContextService(),
687796
);
688797

689798
const testUrls = [
@@ -732,6 +841,7 @@ suite('FetchWebPageTool', () => {
732841
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()), // Empty - all file ,
733842
new MockTrustedDomainService([]),
734843
new MockChatService(),
844+
new TestContextService(),
735845
);
736846

737847
const testUrls = [
@@ -766,6 +876,7 @@ suite('FetchWebPageTool', () => {
766876
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
767877
new MockTrustedDomainService([]),
768878
new MockChatService(),
879+
new TestContextService(),
769880
);
770881

771882
const result = await tool.invoke(
@@ -792,6 +903,7 @@ suite('FetchWebPageTool', () => {
792903
new ExtendedTestFileService(fileContentMap),
793904
new MockTrustedDomainService(),
794905
new MockChatService(),
906+
new TestContextService(),
795907
);
796908

797909
const result = await tool.invoke(
@@ -829,6 +941,7 @@ suite('FetchWebPageTool', () => {
829941
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
830942
new MockTrustedDomainService(),
831943
new MockChatService(),
944+
new TestContextService(),
832945
);
833946

834947
const result = await tool.invoke(
@@ -856,6 +969,7 @@ suite('FetchWebPageTool', () => {
856969
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
857970
new MockTrustedDomainService(),
858971
new MockChatService(),
972+
new TestContextService(),
859973
);
860974

861975
const result = await tool.invoke(
@@ -888,6 +1002,7 @@ suite('FetchWebPageTool', () => {
8881002
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
8891003
new MockTrustedDomainService(),
8901004
new MockChatService(),
1005+
new TestContextService(),
8911006
);
8921007

8931008
const result = await tool.invoke(
@@ -914,6 +1029,7 @@ suite('FetchWebPageTool', () => {
9141029
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
9151030
new MockTrustedDomainService(),
9161031
new MockChatService(),
1032+
new TestContextService(),
9171033
);
9181034

9191035
const result = await tool.invoke(

0 commit comments

Comments
 (0)