Skip to content

Commit e0699cd

Browse files
committed
fix: enforce mode file restrictions for apply_diff tool (#4732)
- Modified isToolAllowedForMode to validate file paths in apply_diff args parameter - Added extraction of paths from XML args for multi-file apply_diff operations - Added comprehensive tests for both single and multi-file scenarios - Ensures mode permissions are properly enforced for all edit tools
1 parent 2e2f83b commit e0699cd

File tree

2 files changed

+153
-28
lines changed

2 files changed

+153
-28
lines changed

src/shared/__tests__/modes.test.ts

Lines changed: 139 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -94,56 +94,99 @@ describe("isToolAllowedForMode", () => {
9494
})
9595

9696
it("handles partial streaming cases (path only, no content/diff)", () => {
97-
// Should allow path-only for matching files (no validation yet since content/diff not provided)
98-
expect(
97+
// Should enforce file restrictions even when only path is provided (streaming scenario)
98+
// This prevents bypassing restrictions when parameters arrive separately
99+
100+
// Non-matching files should be rejected immediately when path is provided
101+
expect(() =>
99102
isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, {
100103
path: "test.js",
101104
}),
105+
).toThrow(FileRestrictionError)
106+
107+
expect(() =>
108+
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
109+
path: "test.js",
110+
}),
111+
).toThrow(FileRestrictionError)
112+
113+
// Matching files should be allowed even with path-only
114+
expect(
115+
isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, {
116+
path: "test.md",
117+
}),
102118
).toBe(true)
103119

104120
expect(
105121
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
106-
path: "test.js",
122+
path: "test.md",
107123
}),
108124
).toBe(true)
109125

110-
// Should allow path-only for architect mode too
126+
// Architect mode (built-in) allows markdown files
111127
expect(
112128
isToolAllowedForMode("write_to_file", "architect", [], undefined, {
113-
path: "test.js",
129+
path: "test.md",
114130
}),
115131
).toBe(true)
116132
})
117133

118-
it("applies restrictions to both write_to_file and apply_diff", () => {
119-
// Test write_to_file
120-
const writeResult = isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, {
121-
path: "test.md",
122-
content: "# Test",
134+
it("applies restrictions to all edit tools", () => {
135+
// Test all edit tools with matching files
136+
const editTools = ["write_to_file", "apply_diff", "insert_content", "search_and_replace"]
137+
138+
editTools.forEach((tool) => {
139+
// Matching file should be allowed
140+
const params: any = { path: "test.md" }
141+
if (tool === "write_to_file") params.content = "# Test"
142+
if (tool === "apply_diff") params.diff = "- old\n+ new"
143+
if (tool === "insert_content") params.content = "new line"
144+
if (tool === "search_and_replace") {
145+
params.search = "old"
146+
params.replace = "new"
147+
}
148+
149+
expect(isToolAllowedForMode(tool as any, "markdown-editor", customModes, undefined, params)).toBe(true)
150+
151+
// Non-matching file should be rejected
152+
params.path = "test.js"
153+
expect(() =>
154+
isToolAllowedForMode(tool as any, "markdown-editor", customModes, undefined, params),
155+
).toThrow(FileRestrictionError)
123156
})
124-
expect(writeResult).toBe(true)
157+
})
125158

126-
// Test apply_diff
127-
const diffResult = isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
128-
path: "test.md",
129-
diff: "- old\n+ new",
130-
})
131-
expect(diffResult).toBe(true)
159+
it("prevents bypassing restrictions via parameter streaming", () => {
160+
// This test specifically addresses the bug where apply_diff could bypass
161+
// file restrictions when path arrives before diff content
132162

133-
// Test both with non-matching file
163+
// Step 1: Path arrives first (should be rejected for non-matching files)
134164
expect(() =>
135-
isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, {
136-
path: "test.js",
137-
content: "console.log('test')",
165+
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
166+
path: "test.js", // Non-matching file
138167
}),
139168
).toThrow(FileRestrictionError)
140169

170+
// Step 2: Even when diff arrives later, it should still be rejected
141171
expect(() =>
142172
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
143173
path: "test.js",
144174
diff: "- old\n+ new",
145175
}),
146176
).toThrow(FileRestrictionError)
177+
178+
// Same for other edit tools
179+
expect(() =>
180+
isToolAllowedForMode("insert_content", "markdown-editor", customModes, undefined, {
181+
path: "test.js",
182+
}),
183+
).toThrow(FileRestrictionError)
184+
185+
expect(() =>
186+
isToolAllowedForMode("search_and_replace", "markdown-editor", customModes, undefined, {
187+
path: "test.js",
188+
}),
189+
).toThrow(FileRestrictionError)
147190
})
148191

149192
it("uses description in file restriction error for custom modes", () => {
@@ -203,11 +246,18 @@ describe("isToolAllowedForMode", () => {
203246
}),
204247
).toBe(true)
205248

206-
// Test partial streaming cases
207-
expect(
249+
// Test partial streaming cases - should now reject non-matching files immediately
250+
expect(() =>
208251
isToolAllowedForMode("write_to_file", "docs-editor", customModesWithDescription, undefined, {
209252
path: "test.js",
210253
}),
254+
).toThrow(FileRestrictionError)
255+
256+
// But should allow matching files even with path-only
257+
expect(
258+
isToolAllowedForMode("write_to_file", "docs-editor", customModesWithDescription, undefined, {
259+
path: "test.md",
260+
}),
211261
).toBe(true)
212262
})
213263

@@ -247,6 +297,72 @@ describe("isToolAllowedForMode", () => {
247297
expect(isToolAllowedForMode("browser_action", "architect", [])).toBe(true)
248298
expect(isToolAllowedForMode("use_mcp_tool", "architect", [])).toBe(true)
249299
})
300+
it("validates paths in multi-file apply_diff args parameter", () => {
301+
// Test multi-file apply_diff with XML args containing non-matching files
302+
const argsWithNonMatchingFile = `<args>
303+
<file>
304+
<path>test.js</path>
305+
<diff>
306+
<content>- old
307+
+ new</content>
308+
</diff>
309+
</file>
310+
</args>`
311+
312+
expect(() =>
313+
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
314+
args: argsWithNonMatchingFile,
315+
}),
316+
).toThrow(FileRestrictionError)
317+
318+
// Test with multiple files, one non-matching
319+
const argsWithMixedFiles = `<args>
320+
<file>
321+
<path>doc.md</path>
322+
<diff>
323+
<content>- old
324+
+ new</content>
325+
</diff>
326+
</file>
327+
<file>
328+
<path>script.js</path>
329+
<diff>
330+
<content>- old
331+
+ new</content>
332+
</diff>
333+
</file>
334+
</args>`
335+
336+
expect(() =>
337+
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
338+
args: argsWithMixedFiles,
339+
}),
340+
).toThrow(FileRestrictionError)
341+
342+
// Test with only matching files (should pass)
343+
const argsWithMatchingFiles = `<args>
344+
<file>
345+
<path>doc1.md</path>
346+
<diff>
347+
<content>- old
348+
+ new</content>
349+
</diff>
350+
</file>
351+
<file>
352+
<path>doc2.md</path>
353+
<diff>
354+
<content>- old
355+
+ new</content>
356+
</diff>
357+
</file>
358+
</args>`
359+
360+
expect(
361+
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
362+
args: argsWithMatchingFiles,
363+
}),
364+
).toBe(true)
365+
})
250366
})
251367

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

src/shared/modes.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,14 +257,23 @@ export function isToolAllowedForMode(
257257

258258
// For the edit group, check file regex if specified
259259
if (groupName === "edit" && options.fileRegex) {
260+
// Check direct path parameter
260261
const filePath = toolParams?.path
261-
if (
262-
filePath &&
263-
(toolParams.diff || toolParams.content || toolParams.operations) &&
264-
!doesFileMatchRegex(filePath, options.fileRegex)
265-
) {
262+
if (filePath && !doesFileMatchRegex(filePath, options.fileRegex)) {
266263
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath)
267264
}
265+
266+
// For apply_diff with multi-file support, also check paths in args parameter
267+
if (tool === "apply_diff" && toolParams?.args && typeof toolParams.args === "string") {
268+
// Extract all file paths from the XML args
269+
const pathMatches = toolParams.args.matchAll(/<path>([^<]+)<\/path>/g)
270+
for (const match of pathMatches) {
271+
const xmlPath = match[1]
272+
if (xmlPath && !doesFileMatchRegex(xmlPath, options.fileRegex)) {
273+
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, xmlPath)
274+
}
275+
}
276+
}
268277
}
269278

270279
return true

0 commit comments

Comments
 (0)