Skip to content

Commit e64cb01

Browse files
authored
[Search Subagent] Don't disclose paths outside of current workspace (#317213)
* wip * filter out files outside of workspace from subagent response * revert settings.json * address code review comments * retrigger gh check
1 parent 9687270 commit e64cb01

2 files changed

Lines changed: 115 additions & 20 deletions

File tree

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

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { IBuildPromptContext } from '../../prompt/common/intents';
2525
import { SearchSubagentToolCallingLoop } from '../../prompt/node/searchSubagentToolCallingLoop';
2626
import { ToolName } from '../common/toolNames';
2727
import { CopilotToolMode, ICopilotTool, ICopilotToolCtor, ToolRegistry } from '../common/toolsRegistry';
28+
import { assertFileOkForTool, isFileExternalAndNeedsConfirmation } from './toolUtils';
2829

2930
export interface ISearchSubagentParams {
3031

@@ -174,7 +175,7 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
174175
subagentResponse = `The search subagent request failed with this message:\n${loopResult.response.type}: ${loopResult.response.reason}`;
175176
}
176177
// Parse and hydrate code snippets from <final_answer> tags
177-
const hydratedResponse = await this.parseFinalAnswerAndHydrate(subagentResponse, cwd, token);
178+
const hydratedResponse = await this.parseFinalAnswerAndHydrate(subagentResponse, cwd, options.workingDirectory, token);
178179

179180
// toolMetadata will be automatically included in exportAllPromptLogsAsJsonCommand
180181
const result = new ExtendedLanguageModelToolResult([new LanguageModelTextPart(hydratedResponse)]);
@@ -187,10 +188,11 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
187188
* Parse the path and line range subagent response and hydrate code snippets
188189
* @param response The subagent response containing paths and line ranges
189190
* @param cwd The current working directory to prepend to relative paths
191+
* @param workingDirectory The working directory URI from tool invocation context
190192
* @param token Cancellation token
191193
* @returns The response with actual code snippets appended to file paths
192194
*/
193-
private async parseFinalAnswerAndHydrate(response: string, cwd: string | undefined, token: vscode.CancellationToken): Promise<string> {
195+
private async parseFinalAnswerAndHydrate(response: string, cwd: string | undefined, workingDirectory: URI | undefined, token: vscode.CancellationToken): Promise<string> {
194196
const lines = response.split('\n');
195197

196198
// Parse file:line-line format
@@ -211,12 +213,17 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
211213
const startLine = parseInt(startLineStr, 10);
212214
const endLine = parseInt(endLineStr, 10);
213215

216+
// Resolve the candidate URI up front so we can reference it from both the
217+
// try and the catch block (for the external-file check below).
218+
const uri = (!path.isAbsolute(filePath) && cwd)
219+
? URI.joinPath(URI.file(cwd), filePath)
220+
: URI.file(filePath);
221+
214222
try {
215-
// For relative paths, immediately resolve against cwd.
216-
// For absolute paths, use as-is and let openTextDocument throw if not found.
217-
const uri = (!path.isAbsolute(filePath) && cwd)
218-
? URI.joinPath(URI.file(cwd), filePath)
219-
: URI.file(filePath);
223+
// Enforce read-only file access via shared toolUtils guards before hydrating.
224+
await this.instantiationService.invokeFunction(accessor =>
225+
assertFileOkForTool(accessor, uri, this._inputContext, { readOnly: true, workingDirectory })
226+
);
220227
const document = await this.workspaceService.openTextDocument(uri);
221228

222229
const snapshot = TextDocumentSnapshot.create(document);
@@ -232,9 +239,24 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
232239
const code = snapshot.getText(range);
233240
processedLines.push(`File: \`${uri.fsPath}\`, lines ${clampedStartLine}-${clampedEndLine}:\n\`\`\`\n${code}\n\`\`\``);
234241
} catch {
235-
// If hydration fails (e.g. the captured path didn't resolve because the model's formatting drifted),
236-
// keep the original line so the main agent still gets the model's answer instead of a noisy error suffix.
237-
processedLines.push(line);
242+
// Drop the line entirely for files outside the workspace so we don't
243+
// disclose the path back to the model. For inside-workspace failures
244+
// (e.g. file missing), keep the original line with the error.
245+
let isExternal = false;
246+
try {
247+
isExternal = await this.instantiationService.invokeFunction(accessor =>
248+
isFileExternalAndNeedsConfirmation(accessor, uri, this._inputContext, { readOnly: true, workingDirectory })
249+
);
250+
} catch {
251+
// isFileExternalAndNeedsConfirmation throws for nonexistent files;
252+
// treat that as "not external" so the original line is preserved.
253+
}
254+
255+
if (!isExternal) {
256+
// If hydration fails (e.g. the captured path didn't resolve because the model's formatting drifted),
257+
// keep the original line so the main agent still gets the model's answer instead of a noisy error suffix.
258+
processedLines.push(line);
259+
}
238260
}
239261

240262
if (token.isCancellationRequested) {

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

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,39 @@
66
import type * as vscode from 'vscode';
77
import { expect, suite, test } from 'vitest';
88
import { ConfigKey } from '../../../../platform/configuration/common/configurationService';
9+
import { URI } from '../../../../util/vs/base/common/uri';
910
import { toolCategories, ToolCategory, ToolName } from '../../common/toolNames';
1011
import { ToolRegistry } from '../../common/toolsRegistry';
1112

1213
// Ensure side-effect registration
1314
import '../searchSubagentTool';
1415

16+
/**
17+
* Returns an invokeFunction stub that dequeues outcomes in call order.
18+
* Each outcome is either a value to resolve with, or a thunk that throws.
19+
*/
20+
function sequencedInvokeFunction(...outcomes: Array<unknown | (() => never)>) {
21+
let i = 0;
22+
return async (_fn: unknown) => {
23+
const outcome = outcomes[i++];
24+
if (typeof outcome === 'function') {
25+
return (outcome as () => unknown)();
26+
}
27+
return outcome;
28+
};
29+
}
30+
31+
/** Minimal vscode.TextDocument-shaped object that satisfies TextDocumentSnapshot.create. */
32+
function makeFakeDocument(uri: URI, text: string) {
33+
return {
34+
uri,
35+
getText: () => text,
36+
languageId: 'typescript',
37+
eol: 1,
38+
version: 0,
39+
} as unknown as vscode.TextDocument;
40+
}
41+
1542
/** Minimal stub for LanguageModelToolInformation */
1643
function makeToolInfo(overrides: Partial<vscode.LanguageModelToolInformation> = {}): vscode.LanguageModelToolInformation {
1744
return {
@@ -25,7 +52,14 @@ function makeToolInfo(overrides: Partial<vscode.LanguageModelToolInformation> =
2552
}
2653

2754
/** Returns an instance of the (private) SearchSubagentTool via the registry */
28-
function makeToolInstance(thoroughnessEnabled: boolean, toolCallLimit: number = 4) {
55+
function makeToolInstance(
56+
thoroughnessEnabled: boolean,
57+
toolCallLimit: number = 4,
58+
overrides: {
59+
invokeFunction?: (fn: unknown) => Promise<unknown>;
60+
openTextDocument?: (uri: URI) => Promise<vscode.TextDocument>;
61+
} = {},
62+
) {
2963
const toolCtor = ToolRegistry.getTools().find(t => t.toolName === ToolName.SearchSubagent)!;
3064

3165
const configService = {
@@ -49,12 +83,18 @@ function makeToolInstance(thoroughnessEnabled: boolean, toolCallLimit: number =
4983
// Return a minimal stub that exposes run()
5084
return { run: async () => ({ response: { type: 'error', reason: 'stub' }, toolCallRounds: [], round: { response: '' } }) };
5185
},
86+
invokeFunction: overrides.invokeFunction ?? (async () => { throw new Error('invokeFunction not stubbed'); }),
87+
};
88+
89+
const workspaceService = {
90+
getWorkspaceFolders: () => [],
91+
openTextDocument: overrides.openTextDocument ?? (async () => { throw new Error('openTextDocument not stubbed'); }),
5292
};
5393

5494
const tool = new (toolCtor as any)(
5595
instantiationService,
5696
{ captureInvocation: async (_token: unknown, fn: () => unknown) => fn() }, // requestLogger
57-
{ getWorkspaceFolders: () => [], openTextDocument: async () => { throw new Error('stub'); } }, // workspaceService
97+
workspaceService,
5898
configService,
5999
experimentationService,
60100
);
@@ -182,21 +222,54 @@ suite('SearchSubagentTool', () => {
182222
'- /workspace/other.ts (lines 30-40): test2',
183223
].join('\n');
184224

185-
const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', notCancelled);
225+
const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', undefined, notCancelled);
186226

187227
expect(result).toBe(response);
188228
});
189229

190-
test('keeps a matching line verbatim when the captured path fails to open (no error suffix)', async () => {
191-
const { tool } = makeToolInstance(false);
192-
const response = [
193-
'- /workspace/file.ts:10-20'
194-
].join('\n');
230+
test('drops the line when the path is outside the workspace', async () => {
231+
const { tool } = makeToolInstance(false, 4, {
232+
invokeFunction: sequencedInvokeFunction(
233+
() => { throw new Error('outside workspace'); },
234+
true,
235+
),
236+
});
237+
238+
const response = '/external/secret.ts:5-10';
239+
const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', undefined, notCancelled);
240+
241+
expect(result).toBe('');
242+
});
243+
244+
test('keeps the original line when an inside-workspace path fails to open', async () => {
245+
const { tool } = makeToolInstance(false, 4, {
246+
invokeFunction: sequencedInvokeFunction(
247+
undefined,
248+
false,
249+
),
250+
openTextDocument: async () => { throw new Error('file not found'); },
251+
});
195252

196-
const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', notCancelled);
253+
const response = 'inside/file.ts:5-10';
254+
const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', undefined, notCancelled);
197255

198256
expect(result).toBe(response);
199-
expect(result).not.toContain('unable to read file');
257+
});
258+
259+
test('hydrates the line with code when an inside-workspace path opens', async () => {
260+
const cwd = '/workspace';
261+
const filePath = 'inside/file.ts';
262+
const fileText = 'line1\nline2';
263+
const uri = URI.joinPath(URI.file(cwd), filePath);
264+
265+
const { tool } = makeToolInstance(false, 4, {
266+
invokeFunction: sequencedInvokeFunction(undefined),
267+
openTextDocument: async () => makeFakeDocument(uri, fileText),
268+
});
269+
270+
const result = await tool['parseFinalAnswerAndHydrate'](`${filePath}:1-2`, cwd, undefined, notCancelled);
271+
272+
expect(result).toBe(`File: \`${uri.fsPath}\`, lines 1-2:\n\`\`\`\n${fileText}\n\`\`\``);
200273
});
201274
});
202275
});

0 commit comments

Comments
 (0)