Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit f6006c9

Browse files
roomote[bot]roomote
andauthored
Fix: Enforce file restrictions for all editing tools (#10896)
Co-authored-by: Roo Code <roomote@roocode.com>
1 parent 5c1c16c commit f6006c9

2 files changed

Lines changed: 300 additions & 2 deletions

File tree

src/core/tools/validateToolUse.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,46 @@ export function validateToolUse(
6262
}
6363
}
6464

65-
const EDIT_OPERATION_PARAMS = ["diff", "content", "operations", "search", "replace", "args", "line"] as const
65+
const EDIT_OPERATION_PARAMS = [
66+
"diff",
67+
"content",
68+
"operations",
69+
"search",
70+
"replace",
71+
"args",
72+
"line",
73+
"patch", // Used by apply_patch
74+
"old_string", // Used by search_replace and edit_file
75+
"new_string", // Used by search_replace and edit_file
76+
] as const
77+
78+
// Markers used in apply_patch format to identify file operations
79+
const PATCH_FILE_MARKERS = ["*** Add File: ", "*** Delete File: ", "*** Update File: "] as const
80+
81+
/**
82+
* Extract file paths from apply_patch content.
83+
* The patch format uses markers like "*** Add File: path", "*** Delete File: path", "*** Update File: path"
84+
* @param patchContent The patch content string
85+
* @returns Array of file paths found in the patch
86+
*/
87+
function extractFilePathsFromPatch(patchContent: string): string[] {
88+
const filePaths: string[] = []
89+
const lines = patchContent.split("\n")
90+
91+
for (const line of lines) {
92+
for (const marker of PATCH_FILE_MARKERS) {
93+
if (line.startsWith(marker)) {
94+
const path = line.substring(marker.length).trim()
95+
if (path) {
96+
filePaths.push(path)
97+
}
98+
break
99+
}
100+
}
101+
}
102+
103+
return filePaths
104+
}
66105

67106
function getGroupOptions(group: GroupEntry): GroupOptions | undefined {
68107
return Array.isArray(group) ? group[1] : undefined
@@ -155,7 +194,7 @@ export function isToolAllowedForMode(
155194

156195
// For the edit group, check file regex if specified
157196
if (groupName === "edit" && options.fileRegex) {
158-
const filePath = toolParams?.path
197+
const filePath = toolParams?.path || toolParams?.file_path
159198
// Check if this is an actual edit operation (not just path-only for streaming)
160199
const isEditOperation = EDIT_OPERATION_PARAMS.some((param) => toolParams?.[param])
161200

@@ -164,6 +203,22 @@ export function isToolAllowedForMode(
164203
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath, tool)
165204
}
166205

206+
// Handle apply_patch: extract file paths from patch content and validate each
207+
if (tool === "apply_patch" && typeof toolParams?.patch === "string") {
208+
const patchFilePaths = extractFilePathsFromPatch(toolParams.patch)
209+
for (const patchFilePath of patchFilePaths) {
210+
if (!doesFileMatchRegex(patchFilePath, options.fileRegex)) {
211+
throw new FileRestrictionError(
212+
mode.name,
213+
options.fileRegex,
214+
options.description,
215+
patchFilePath,
216+
tool,
217+
)
218+
}
219+
}
220+
}
221+
167222
// Native-only: multi-file edits provide structured params; no legacy XML args parsing.
168223
}
169224

src/shared/__tests__/modes.spec.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,249 @@ describe("isToolAllowedForMode", () => {
273273
}),
274274
).toThrow(/Markdown files only/)
275275
})
276+
277+
it("applies restrictions to apply_patch (custom tool)", () => {
278+
// Test that apply_patch respects file restrictions when included
279+
// Note: apply_patch only accepts { patch: string } - file paths are embedded in patch content
280+
const patchResult = isToolAllowedForMode(
281+
"apply_patch",
282+
"markdown-editor",
283+
customModes,
284+
undefined,
285+
{
286+
patch: "*** Begin Patch\n*** Update File: test.md\n@@ \n-old\n+new\n*** End Patch",
287+
},
288+
undefined,
289+
["apply_patch"], // Include custom tool
290+
)
291+
expect(patchResult).toBe(true)
292+
293+
// Test apply_patch with non-matching file (file path embedded in patch content)
294+
expect(() =>
295+
isToolAllowedForMode(
296+
"apply_patch",
297+
"markdown-editor",
298+
customModes,
299+
undefined,
300+
{
301+
patch: "*** Begin Patch\n*** Update File: test.js\n@@ \n-old\n+new\n*** End Patch",
302+
},
303+
undefined,
304+
["apply_patch"], // Include custom tool
305+
),
306+
).toThrow(FileRestrictionError)
307+
expect(() =>
308+
isToolAllowedForMode(
309+
"apply_patch",
310+
"markdown-editor",
311+
customModes,
312+
undefined,
313+
{
314+
patch: "*** Begin Patch\n*** Update File: test.js\n@@ \n-old\n+new\n*** End Patch",
315+
},
316+
undefined,
317+
["apply_patch"], // Include custom tool
318+
),
319+
).toThrow(/\\.md\$/)
320+
})
321+
322+
it("applies restrictions to search_replace (custom tool)", () => {
323+
// Test that search_replace respects file restrictions when included
324+
const searchReplaceResult = isToolAllowedForMode(
325+
"search_replace",
326+
"markdown-editor",
327+
customModes,
328+
undefined,
329+
{
330+
file_path: "test.md",
331+
old_string: "old text",
332+
new_string: "new text",
333+
},
334+
undefined,
335+
["search_replace"], // Include custom tool
336+
)
337+
expect(searchReplaceResult).toBe(true)
338+
339+
// Test search_replace with non-matching file
340+
expect(() =>
341+
isToolAllowedForMode(
342+
"search_replace",
343+
"markdown-editor",
344+
customModes,
345+
undefined,
346+
{
347+
file_path: "test.js",
348+
old_string: "old text",
349+
new_string: "new text",
350+
},
351+
undefined,
352+
["search_replace"], // Include custom tool
353+
),
354+
).toThrow(FileRestrictionError)
355+
expect(() =>
356+
isToolAllowedForMode(
357+
"search_replace",
358+
"markdown-editor",
359+
customModes,
360+
undefined,
361+
{
362+
file_path: "test.js",
363+
old_string: "old text",
364+
new_string: "new text",
365+
},
366+
undefined,
367+
["search_replace"], // Include custom tool
368+
),
369+
).toThrow(/\\.md\$/)
370+
})
371+
372+
it("applies restrictions to edit_file (custom tool)", () => {
373+
// Test that edit_file respects file restrictions when included
374+
const editFileResult = isToolAllowedForMode(
375+
"edit_file",
376+
"markdown-editor",
377+
customModes,
378+
undefined,
379+
{
380+
file_path: "test.md",
381+
old_string: "old text",
382+
new_string: "new text",
383+
},
384+
undefined,
385+
["edit_file"], // Include custom tool
386+
)
387+
expect(editFileResult).toBe(true)
388+
389+
// Test edit_file with non-matching file
390+
expect(() =>
391+
isToolAllowedForMode(
392+
"edit_file",
393+
"markdown-editor",
394+
customModes,
395+
undefined,
396+
{
397+
file_path: "test.js",
398+
old_string: "old text",
399+
new_string: "new text",
400+
},
401+
undefined,
402+
["edit_file"], // Include custom tool
403+
),
404+
).toThrow(FileRestrictionError)
405+
expect(() =>
406+
isToolAllowedForMode(
407+
"edit_file",
408+
"markdown-editor",
409+
customModes,
410+
undefined,
411+
{
412+
file_path: "test.js",
413+
old_string: "old text",
414+
new_string: "new text",
415+
},
416+
undefined,
417+
["edit_file"], // Include custom tool
418+
),
419+
).toThrow(/\\.md\$/)
420+
})
421+
422+
it("applies restrictions to all editing tools in architect mode (custom tools)", () => {
423+
// Test apply_patch in architect mode
424+
// Note: apply_patch only accepts { patch: string } - file paths are embedded in patch content
425+
expect(
426+
isToolAllowedForMode(
427+
"apply_patch",
428+
"architect",
429+
[],
430+
undefined,
431+
{
432+
patch: "*** Begin Patch\n*** Update File: test.md\n@@ \n-old\n+new\n*** End Patch",
433+
},
434+
undefined,
435+
["apply_patch"], // Include custom tool
436+
),
437+
).toBe(true)
438+
439+
expect(() =>
440+
isToolAllowedForMode(
441+
"apply_patch",
442+
"architect",
443+
[],
444+
undefined,
445+
{
446+
patch: "*** Begin Patch\n*** Update File: test.js\n@@ \n-old\n+new\n*** End Patch",
447+
},
448+
undefined,
449+
["apply_patch"], // Include custom tool
450+
),
451+
).toThrow(FileRestrictionError)
452+
453+
// Test search_replace in architect mode
454+
expect(
455+
isToolAllowedForMode(
456+
"search_replace",
457+
"architect",
458+
[],
459+
undefined,
460+
{
461+
file_path: "test.md",
462+
old_string: "old text",
463+
new_string: "new text",
464+
},
465+
undefined,
466+
["search_replace"], // Include custom tool
467+
),
468+
).toBe(true)
469+
470+
expect(() =>
471+
isToolAllowedForMode(
472+
"search_replace",
473+
"architect",
474+
[],
475+
undefined,
476+
{
477+
file_path: "test.js",
478+
old_string: "old text",
479+
new_string: "new text",
480+
},
481+
undefined,
482+
["search_replace"], // Include custom tool
483+
),
484+
).toThrow(FileRestrictionError)
485+
486+
// Test edit_file in architect mode
487+
expect(
488+
isToolAllowedForMode(
489+
"edit_file",
490+
"architect",
491+
[],
492+
undefined,
493+
{
494+
file_path: "test.md",
495+
old_string: "old text",
496+
new_string: "new text",
497+
},
498+
undefined,
499+
["edit_file"], // Include custom tool
500+
),
501+
).toBe(true)
502+
503+
expect(() =>
504+
isToolAllowedForMode(
505+
"edit_file",
506+
"architect",
507+
[],
508+
undefined,
509+
{
510+
file_path: "test.js",
511+
old_string: "old text",
512+
new_string: "new text",
513+
},
514+
undefined,
515+
["edit_file"], // Include custom tool
516+
),
517+
).toThrow(FileRestrictionError)
518+
})
276519
})
277520

278521
it("handles non-existent modes", () => {

0 commit comments

Comments
 (0)