Skip to content

Commit e756498

Browse files
committed
Limit search_files to only look within the workspace
1 parent dc00167 commit e756498

File tree

2 files changed

+287
-0
lines changed

2 files changed

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

src/core/tools/searchFilesTool.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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"
89

910
export async function searchFilesTool(
@@ -49,6 +50,15 @@ export async function searchFilesTool(
4950

5051
const absolutePath = path.resolve(cline.cwd, relDirPath)
5152

53+
// Check if path is outside workspace
54+
if (isPathOutsideWorkspace(absolutePath)) {
55+
const errorMessage = `Cannot search outside workspace. Path '${relDirPath}' is outside the current workspace.`
56+
cline.consecutiveMistakeCount++
57+
cline.recordToolError("search_files")
58+
pushToolResult(errorMessage)
59+
return
60+
}
61+
5262
const results = await regexSearchFiles(
5363
cline.cwd,
5464
absolutePath,

0 commit comments

Comments
 (0)