Skip to content

Commit ca0338a

Browse files
authored
Limit search_files to only look within the workspace (#4642)
1 parent e535b55 commit ca0338a

File tree

19 files changed

+364
-0
lines changed

19 files changed

+364
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import path from "path"
2+
import { describe, it, expect, beforeEach, vi, type Mock, type MockedFunction } from "vitest"
3+
import { searchFilesTool } from "../searchFilesTool"
4+
import { Task } from "../../task/Task"
5+
import { SearchFilesToolUse } from "../../../shared/tools"
6+
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
7+
import { regexSearchFiles } from "../../../services/ripgrep"
8+
import { RooIgnoreController } from "../../ignore/RooIgnoreController"
9+
10+
// Mock dependencies
11+
vi.mock("../../../utils/pathUtils", () => ({
12+
isPathOutsideWorkspace: vi.fn(),
13+
}))
14+
15+
vi.mock("../../../services/ripgrep", () => ({
16+
regexSearchFiles: vi.fn(),
17+
}))
18+
19+
vi.mock("../../../utils/path", () => ({
20+
getReadablePath: vi.fn((cwd: string, relPath: string) => relPath),
21+
}))
22+
23+
vi.mock("../../ignore/RooIgnoreController")
24+
25+
vi.mock("../../../i18n", () => ({
26+
t: vi.fn((key: string, params?: any) => {
27+
if (key === "tools:searchFiles.workspaceBoundaryError") {
28+
return `Cannot search outside workspace. Path '${params?.path}' is outside the current workspace.`
29+
}
30+
return key
31+
}),
32+
}))
33+
34+
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
35+
const mockedRegexSearchFiles = regexSearchFiles as MockedFunction<typeof regexSearchFiles>
36+
37+
describe("searchFilesTool", () => {
38+
let mockTask: Partial<Task>
39+
let mockAskApproval: Mock
40+
let mockHandleError: Mock
41+
let mockPushToolResult: Mock
42+
let mockRemoveClosingTag: Mock
43+
44+
beforeEach(() => {
45+
vi.clearAllMocks()
46+
47+
mockTask = {
48+
cwd: "/workspace",
49+
consecutiveMistakeCount: 0,
50+
recordToolError: vi.fn(),
51+
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
52+
say: vi.fn().mockResolvedValue(undefined),
53+
rooIgnoreController: new RooIgnoreController("/workspace"),
54+
}
55+
56+
mockAskApproval = vi.fn().mockResolvedValue(true)
57+
mockHandleError = vi.fn()
58+
mockPushToolResult = vi.fn()
59+
mockRemoveClosingTag = vi.fn((tag: string, value: string | undefined) => value || "")
60+
61+
mockedRegexSearchFiles.mockResolvedValue("Search results")
62+
})
63+
64+
describe("workspace boundary validation", () => {
65+
it("should allow search within workspace", async () => {
66+
const block: SearchFilesToolUse = {
67+
type: "tool_use",
68+
name: "search_files",
69+
params: {
70+
path: "src",
71+
regex: "test",
72+
file_pattern: "*.ts",
73+
},
74+
partial: false,
75+
}
76+
77+
mockedIsPathOutsideWorkspace.mockReturnValue(false)
78+
79+
await searchFilesTool(
80+
mockTask as Task,
81+
block,
82+
mockAskApproval,
83+
mockHandleError,
84+
mockPushToolResult,
85+
mockRemoveClosingTag,
86+
)
87+
88+
expect(mockedIsPathOutsideWorkspace).toHaveBeenCalledWith(path.resolve("/workspace", "src"))
89+
expect(mockedRegexSearchFiles).toHaveBeenCalled()
90+
expect(mockPushToolResult).toHaveBeenCalledWith("Search results")
91+
})
92+
93+
it("should block search outside workspace", async () => {
94+
const block: SearchFilesToolUse = {
95+
type: "tool_use",
96+
name: "search_files",
97+
params: {
98+
path: "../external",
99+
regex: "test",
100+
file_pattern: "*.ts",
101+
},
102+
partial: false,
103+
}
104+
105+
mockedIsPathOutsideWorkspace.mockReturnValue(true)
106+
107+
await searchFilesTool(
108+
mockTask as Task,
109+
block,
110+
mockAskApproval,
111+
mockHandleError,
112+
mockPushToolResult,
113+
mockRemoveClosingTag,
114+
)
115+
116+
expect(mockedIsPathOutsideWorkspace).toHaveBeenCalledWith(path.resolve("/workspace", "../external"))
117+
expect(mockedRegexSearchFiles).not.toHaveBeenCalled()
118+
expect(mockTask.say).toHaveBeenCalledWith(
119+
"error",
120+
"Cannot search outside workspace. Path '../external' is outside the current workspace.",
121+
)
122+
expect(mockPushToolResult).toHaveBeenCalledWith(
123+
"Cannot search outside workspace. Path '../external' is outside the current workspace.",
124+
)
125+
expect(mockTask.consecutiveMistakeCount).toBe(1)
126+
expect(mockTask.recordToolError).toHaveBeenCalledWith("search_files")
127+
})
128+
129+
it("should block search with absolute path outside workspace", async () => {
130+
const block: SearchFilesToolUse = {
131+
type: "tool_use",
132+
name: "search_files",
133+
params: {
134+
path: "/etc/passwd",
135+
regex: "root",
136+
},
137+
partial: false,
138+
}
139+
140+
mockedIsPathOutsideWorkspace.mockReturnValue(true)
141+
142+
await searchFilesTool(
143+
mockTask as Task,
144+
block,
145+
mockAskApproval,
146+
mockHandleError,
147+
mockPushToolResult,
148+
mockRemoveClosingTag,
149+
)
150+
151+
expect(mockedIsPathOutsideWorkspace).toHaveBeenCalledWith(path.resolve("/workspace", "/etc/passwd"))
152+
expect(mockedRegexSearchFiles).not.toHaveBeenCalled()
153+
expect(mockTask.say).toHaveBeenCalledWith(
154+
"error",
155+
"Cannot search outside workspace. Path '/etc/passwd' is outside the current workspace.",
156+
)
157+
expect(mockPushToolResult).toHaveBeenCalledWith(
158+
"Cannot search outside workspace. Path '/etc/passwd' is outside the current workspace.",
159+
)
160+
})
161+
162+
it("should handle relative paths that resolve outside workspace", async () => {
163+
const block: SearchFilesToolUse = {
164+
type: "tool_use",
165+
name: "search_files",
166+
params: {
167+
path: "../../..",
168+
regex: "sensitive",
169+
},
170+
partial: false,
171+
}
172+
173+
mockedIsPathOutsideWorkspace.mockReturnValue(true)
174+
175+
await searchFilesTool(
176+
mockTask as Task,
177+
block,
178+
mockAskApproval,
179+
mockHandleError,
180+
mockPushToolResult,
181+
mockRemoveClosingTag,
182+
)
183+
184+
expect(mockedIsPathOutsideWorkspace).toHaveBeenCalledWith(path.resolve("/workspace", "../../.."))
185+
expect(mockedRegexSearchFiles).not.toHaveBeenCalled()
186+
expect(mockTask.say).toHaveBeenCalledWith(
187+
"error",
188+
"Cannot search outside workspace. Path '../../..' is outside the current workspace.",
189+
)
190+
expect(mockPushToolResult).toHaveBeenCalledWith(
191+
"Cannot search outside workspace. Path '../../..' is outside the current workspace.",
192+
)
193+
})
194+
})
195+
196+
describe("existing functionality", () => {
197+
beforeEach(() => {
198+
mockedIsPathOutsideWorkspace.mockReturnValue(false)
199+
})
200+
201+
it("should handle missing path parameter", async () => {
202+
const block: SearchFilesToolUse = {
203+
type: "tool_use",
204+
name: "search_files",
205+
params: {
206+
regex: "test",
207+
},
208+
partial: false,
209+
}
210+
211+
await searchFilesTool(
212+
mockTask as Task,
213+
block,
214+
mockAskApproval,
215+
mockHandleError,
216+
mockPushToolResult,
217+
mockRemoveClosingTag,
218+
)
219+
220+
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("search_files", "path")
221+
expect(mockedRegexSearchFiles).not.toHaveBeenCalled()
222+
})
223+
224+
it("should handle missing regex parameter", async () => {
225+
const block: SearchFilesToolUse = {
226+
type: "tool_use",
227+
name: "search_files",
228+
params: {
229+
path: "src",
230+
},
231+
partial: false,
232+
}
233+
234+
await searchFilesTool(
235+
mockTask as Task,
236+
block,
237+
mockAskApproval,
238+
mockHandleError,
239+
mockPushToolResult,
240+
mockRemoveClosingTag,
241+
)
242+
243+
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("search_files", "regex")
244+
expect(mockedRegexSearchFiles).not.toHaveBeenCalled()
245+
})
246+
247+
it("should handle partial blocks", async () => {
248+
const block: SearchFilesToolUse = {
249+
type: "tool_use",
250+
name: "search_files",
251+
params: {
252+
path: "src",
253+
regex: "test",
254+
},
255+
partial: true,
256+
}
257+
258+
const mockAsk = vi.fn()
259+
mockTask.ask = mockAsk
260+
261+
await searchFilesTool(
262+
mockTask as Task,
263+
block,
264+
mockAskApproval,
265+
mockHandleError,
266+
mockPushToolResult,
267+
mockRemoveClosingTag,
268+
)
269+
270+
expect(mockAsk).toHaveBeenCalled()
271+
expect(mockedRegexSearchFiles).not.toHaveBeenCalled()
272+
})
273+
274+
it("should handle user rejection", async () => {
275+
const block: SearchFilesToolUse = {
276+
type: "tool_use",
277+
name: "search_files",
278+
params: {
279+
path: "src",
280+
regex: "test",
281+
},
282+
partial: false,
283+
}
284+
285+
mockAskApproval.mockResolvedValue(false)
286+
287+
await searchFilesTool(
288+
mockTask as Task,
289+
block,
290+
mockAskApproval,
291+
mockHandleError,
292+
mockPushToolResult,
293+
mockRemoveClosingTag,
294+
)
295+
296+
expect(mockedRegexSearchFiles).toHaveBeenCalled()
297+
expect(mockPushToolResult).not.toHaveBeenCalled()
298+
})
299+
})
300+
})

src/core/tools/searchFilesTool.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { Task } from "../task/Task"
44
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
55
import { ClineSayTool } from "../../shared/ExtensionMessage"
66
import { getReadablePath } from "../../utils/path"
7+
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
78
import { regexSearchFiles } from "../../services/ripgrep"
9+
import { t } from "../../i18n"
810

911
export async function searchFilesTool(
1012
cline: Task,
@@ -49,6 +51,17 @@ export async function searchFilesTool(
4951

5052
const absolutePath = path.resolve(cline.cwd, relDirPath)
5153

54+
// Check if path is outside workspace
55+
if (isPathOutsideWorkspace(absolutePath)) {
56+
const userErrorMessage = t("tools:searchFiles.workspaceBoundaryError", { path: relDirPath })
57+
const llmErrorMessage = `Cannot search outside workspace. Path '${relDirPath}' is outside the current workspace.`
58+
cline.consecutiveMistakeCount++
59+
cline.recordToolError("search_files")
60+
await cline.say("error", userErrorMessage)
61+
pushToolResult(llmErrorMessage)
62+
return
63+
}
64+
5265
const results = await regexSearchFiles(
5366
cline.cwd,
5467
absolutePath,

src/i18n/locales/ca/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo sembla estar atrapat en un bucle, intentant la mateixa acció ({{toolName}}) repetidament. Això podria indicar un problema amb la seva estratègia actual. Considera reformular la tasca, proporcionar instruccions més específiques o guiar-lo cap a un enfocament diferent.",
88
"codebaseSearch": {
99
"approval": "Cercant '{{query}}' a la base de codi..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "No es pot cercar fora de l'espai de treball. El camí '{{path}}' està fora de l'espai de treball actual."
1013
}
1114
}

src/i18n/locales/de/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo scheint in einer Schleife festzustecken und versucht wiederholt dieselbe Aktion ({{toolName}}). Dies könnte auf ein Problem mit der aktuellen Strategie hindeuten. Überlege dir, die Aufgabe umzuformulieren, genauere Anweisungen zu geben oder Roo zu einem anderen Ansatz zu führen.",
88
"codebaseSearch": {
99
"approval": "Suche nach '{{query}}' im Codebase..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "Kann nicht außerhalb des Arbeitsbereichs suchen. Pfad '{{path}}' liegt außerhalb des aktuellen Arbeitsbereichs."
1013
}
1114
}

src/i18n/locales/en/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo appears to be stuck in a loop, attempting the same action ({{toolName}}) repeatedly. This might indicate a problem with its current strategy. Consider rephrasing the task, providing more specific instructions, or guiding it towards a different approach.",
88
"codebaseSearch": {
99
"approval": "Searching for '{{query}}' in codebase..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "Cannot search outside workspace. Path '{{path}}' is outside the current workspace."
1013
}
1114
}

src/i18n/locales/es/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo parece estar atrapado en un bucle, intentando la misma acción ({{toolName}}) repetidamente. Esto podría indicar un problema con su estrategia actual. Considera reformular la tarea, proporcionar instrucciones más específicas o guiarlo hacia un enfoque diferente.",
88
"codebaseSearch": {
99
"approval": "Buscando '{{query}}' en la base de código..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "No se puede buscar fuera del espacio de trabajo. La ruta '{{path}}' está fuera del espacio de trabajo actual."
1013
}
1114
}

src/i18n/locales/fr/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo semble être bloqué dans une boucle, tentant la même action ({{toolName}}) de façon répétée. Cela pourrait indiquer un problème avec sa stratégie actuelle. Envisage de reformuler la tâche, de fournir des instructions plus spécifiques ou de le guider vers une approche différente.",
88
"codebaseSearch": {
99
"approval": "Recherche de '{{query}}' dans la base de code..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "Impossible de rechercher en dehors de l'espace de travail. Le chemin '{{path}}' est en dehors de l'espace de travail actuel."
1013
}
1114
}

src/i18n/locales/hi/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।",
88
"codebaseSearch": {
99
"approval": "कोडबेस में '{{query}}' खोज रहा है..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "वर्कस्पेस के बाहर खोज नहीं की जा सकती। पथ '{{path}}' वर्तमान वर्कस्पेस के बाहर है।"
1013
}
1114
}

src/i18n/locales/it/tools.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"toolRepetitionLimitReached": "Roo sembra essere bloccato in un ciclo, tentando ripetutamente la stessa azione ({{toolName}}). Questo potrebbe indicare un problema con la sua strategia attuale. Considera di riformulare l'attività, fornire istruzioni più specifiche o guidarlo verso un approccio diverso.",
88
"codebaseSearch": {
99
"approval": "Ricerca di '{{query}}' nella base di codice..."
10+
},
11+
"searchFiles": {
12+
"workspaceBoundaryError": "Impossibile cercare al di fuori dell'area di lavoro. Il percorso '{{path}}' è al di fuori dell'area di lavoro corrente."
1013
}
1114
}

0 commit comments

Comments
 (0)