Skip to content

Commit 3289322

Browse files
authored
fix: enforce file restrictions for all edit tools in architect mode (#5445) (#5447)
1 parent 26c2fbb commit 3289322

File tree

2 files changed

+176
-8
lines changed

2 files changed

+176
-8
lines changed

src/shared/__tests__/modes.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,112 @@ describe("isToolAllowedForMode", () => {
245245
expect(isToolAllowedForMode("browser_action", "architect", [])).toBe(true)
246246
expect(isToolAllowedForMode("use_mcp_tool", "architect", [])).toBe(true)
247247
})
248+
249+
it("applies restrictions to all edit tools including search_and_replace and insert_content", () => {
250+
// Test search_and_replace with matching file
251+
expect(
252+
isToolAllowedForMode("search_and_replace", "architect", [], undefined, {
253+
path: "test.md",
254+
search: "old text",
255+
replace: "new text",
256+
}),
257+
).toBe(true)
258+
259+
// Test insert_content with matching file
260+
expect(
261+
isToolAllowedForMode("insert_content", "architect", [], undefined, {
262+
path: "test.md",
263+
line: "1",
264+
content: "# New content",
265+
}),
266+
).toBe(true)
267+
268+
// Test search_and_replace with non-matching file - should throw error
269+
expect(() =>
270+
isToolAllowedForMode("search_and_replace", "architect", [], undefined, {
271+
path: "test.py",
272+
search: "old text",
273+
replace: "new text",
274+
}),
275+
).toThrow(FileRestrictionError)
276+
expect(() =>
277+
isToolAllowedForMode("search_and_replace", "architect", [], undefined, {
278+
path: "test.py",
279+
search: "old text",
280+
replace: "new text",
281+
}),
282+
).toThrow(/Markdown files only/)
283+
284+
// Test insert_content with non-matching file - should throw error
285+
expect(() =>
286+
isToolAllowedForMode("insert_content", "architect", [], undefined, {
287+
path: "test.py",
288+
line: "1",
289+
content: "print('hello')",
290+
}),
291+
).toThrow(FileRestrictionError)
292+
expect(() =>
293+
isToolAllowedForMode("insert_content", "architect", [], undefined, {
294+
path: "test.py",
295+
line: "1",
296+
content: "print('hello')",
297+
}),
298+
).toThrow(/Markdown files only/)
299+
})
300+
301+
it("applies restrictions to apply_diff with concurrent file edits (MULTI_FILE_APPLY_DIFF experiment)", () => {
302+
// Test apply_diff with args parameter (used when MULTI_FILE_APPLY_DIFF experiment is enabled)
303+
// This simulates concurrent/batch file editing
304+
const xmlArgs =
305+
"<args><file><path>test.md</path><diff><content>- old content\\n+ new content</content></diff></file></args>"
306+
307+
// Should allow markdown files in architect mode
308+
expect(
309+
isToolAllowedForMode("apply_diff", "architect", [], undefined, {
310+
args: xmlArgs,
311+
}),
312+
).toBe(true)
313+
314+
// Test with non-markdown file - should throw error
315+
const xmlArgsNonMd =
316+
"<args><file><path>test.py</path><diff><content>- old content\\n+ new content</content></diff></file></args>"
317+
318+
expect(() =>
319+
isToolAllowedForMode("apply_diff", "architect", [], undefined, {
320+
args: xmlArgsNonMd,
321+
}),
322+
).toThrow(FileRestrictionError)
323+
expect(() =>
324+
isToolAllowedForMode("apply_diff", "architect", [], undefined, {
325+
args: xmlArgsNonMd,
326+
}),
327+
).toThrow(/Markdown files only/)
328+
329+
// Test with multiple files - should allow only markdown files
330+
const xmlArgsMultiple =
331+
"<args><file><path>readme.md</path><diff><content>- old content\\n+ new content</content></diff></file><file><path>docs.md</path><diff><content>- old content\\n+ new content</content></diff></file></args>"
332+
333+
expect(
334+
isToolAllowedForMode("apply_diff", "architect", [], undefined, {
335+
args: xmlArgsMultiple,
336+
}),
337+
).toBe(true)
338+
339+
// Test with mixed file types - should throw error for non-markdown
340+
const xmlArgsMixed =
341+
"<args><file><path>readme.md</path><diff><content>- old content\\n+ new content</content></diff></file><file><path>script.py</path><diff><content>- old content\\n+ new content</content></diff></file></args>"
342+
343+
expect(() =>
344+
isToolAllowedForMode("apply_diff", "architect", [], undefined, {
345+
args: xmlArgsMixed,
346+
}),
347+
).toThrow(FileRestrictionError)
348+
expect(() =>
349+
isToolAllowedForMode("apply_diff", "architect", [], undefined, {
350+
args: xmlArgsMixed,
351+
}),
352+
).toThrow(/Markdown files only/)
353+
})
248354
})
249355

250356
it("handles non-existent modes", () => {
@@ -269,6 +375,14 @@ describe("FileRestrictionError", () => {
269375
expect(error.name).toBe("FileRestrictionError")
270376
})
271377

378+
it("formats error message with tool name when provided", () => {
379+
const error = new FileRestrictionError("Markdown Editor", "\\.md$", undefined, "test.js", "write_to_file")
380+
expect(error.message).toBe(
381+
"Tool 'write_to_file' in mode 'Markdown Editor' can only edit files matching pattern: \\.md$. Got: test.js",
382+
)
383+
expect(error.name).toBe("FileRestrictionError")
384+
})
385+
272386
describe("debug mode", () => {
273387
it("is configured correctly", () => {
274388
const debugMode = modes.find((mode) => mode.slug === "debug")
@@ -368,6 +482,20 @@ describe("FileRestrictionError", () => {
368482
)
369483
expect(error.name).toBe("FileRestrictionError")
370484
})
485+
486+
it("formats error message with both tool name and description when provided", () => {
487+
const error = new FileRestrictionError(
488+
"Markdown Editor",
489+
"\\.md$",
490+
"Markdown files only",
491+
"test.js",
492+
"apply_diff",
493+
)
494+
expect(error.message).toBe(
495+
"Tool 'apply_diff' in mode 'Markdown Editor' can only edit files matching pattern: \\.md$ (Markdown files only). Got: test.js",
496+
)
497+
expect(error.name).toBe("FileRestrictionError")
498+
})
371499
})
372500

373501
describe("getModeSelection", () => {

src/shared/modes.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,15 @@ export function getModeSelection(mode: string, promptComponent?: PromptComponent
209209
}
210210
}
211211

212+
// Edit operation parameters that indicate an actual edit operation
213+
const EDIT_OPERATION_PARAMS = ["diff", "content", "operations", "search", "replace", "args", "line"] as const
214+
212215
// Custom error class for file restrictions
213216
export class FileRestrictionError extends Error {
214-
constructor(mode: string, pattern: string, description: string | undefined, filePath: string) {
217+
constructor(mode: string, pattern: string, description: string | undefined, filePath: string, tool?: string) {
218+
const toolInfo = tool ? `Tool '${tool}' in mode '${mode}'` : `This mode (${mode})`
215219
super(
216-
`This mode (${mode}) can only edit files matching pattern: ${pattern}${description ? ` (${description})` : ""}. Got: ${filePath}`,
220+
`${toolInfo} can only edit files matching pattern: ${pattern}${description ? ` (${description})` : ""}. Got: ${filePath}`,
217221
)
218222
this.name = "FileRestrictionError"
219223
}
@@ -272,12 +276,48 @@ export function isToolAllowedForMode(
272276
// For the edit group, check file regex if specified
273277
if (groupName === "edit" && options.fileRegex) {
274278
const filePath = toolParams?.path
275-
if (
276-
filePath &&
277-
(toolParams.diff || toolParams.content || toolParams.operations) &&
278-
!doesFileMatchRegex(filePath, options.fileRegex)
279-
) {
280-
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath)
279+
// Check if this is an actual edit operation (not just path-only for streaming)
280+
const isEditOperation = EDIT_OPERATION_PARAMS.some((param) => toolParams?.[param])
281+
282+
// Handle single file path validation
283+
if (filePath && isEditOperation && !doesFileMatchRegex(filePath, options.fileRegex)) {
284+
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath, tool)
285+
}
286+
287+
// Handle XML args parameter (used by MULTI_FILE_APPLY_DIFF experiment)
288+
if (toolParams?.args && typeof toolParams.args === "string") {
289+
// Extract file paths from XML args with improved validation
290+
try {
291+
const filePathMatches = toolParams.args.match(/<path>([^<]+)<\/path>/g)
292+
if (filePathMatches) {
293+
for (const match of filePathMatches) {
294+
// More robust path extraction with validation
295+
const pathMatch = match.match(/<path>([^<]+)<\/path>/)
296+
if (pathMatch && pathMatch[1]) {
297+
const extractedPath = pathMatch[1].trim()
298+
// Validate that the path is not empty and doesn't contain invalid characters
299+
if (extractedPath && !extractedPath.includes("<") && !extractedPath.includes(">")) {
300+
if (!doesFileMatchRegex(extractedPath, options.fileRegex)) {
301+
throw new FileRestrictionError(
302+
mode.name,
303+
options.fileRegex,
304+
options.description,
305+
extractedPath,
306+
tool,
307+
)
308+
}
309+
}
310+
}
311+
}
312+
}
313+
} catch (error) {
314+
// Re-throw FileRestrictionError as it's an expected validation error
315+
if (error instanceof FileRestrictionError) {
316+
throw error
317+
}
318+
// If XML parsing fails, log the error but don't block the operation
319+
console.warn(`Failed to parse XML args for file restriction validation: ${error}`)
320+
}
281321
}
282322
}
283323

0 commit comments

Comments
 (0)