-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix: block unsafe root-like delete_file paths #2859
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import fs from "node:fs"; | ||
| import { deleteFileTool } from "./delete_file"; | ||
| import type { AgentContext } from "./types"; | ||
| import { gitRemove } from "@/ipc/utils/git_utils"; | ||
|
|
||
| vi.mock("node:fs", async () => { | ||
| const actual = await vi.importActual<typeof import("node:fs")>("node:fs"); | ||
| return { | ||
| ...actual, | ||
| default: { | ||
| existsSync: vi.fn(), | ||
| lstatSync: vi.fn(), | ||
| rmdirSync: vi.fn(), | ||
| unlinkSync: vi.fn(), | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock("electron-log", () => ({ | ||
| default: { | ||
| scope: () => ({ | ||
| log: vi.fn(), | ||
| warn: vi.fn(), | ||
| error: vi.fn(), | ||
| debug: vi.fn(), | ||
| }), | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock("@/ipc/utils/git_utils", () => ({ | ||
| gitRemove: vi.fn().mockResolvedValue(undefined), | ||
| })); | ||
|
|
||
| vi.mock("../../../../../../supabase_admin/supabase_management_client", () => ({ | ||
| deleteSupabaseFunction: vi.fn().mockResolvedValue(undefined), | ||
| })); | ||
|
|
||
| describe("deleteFileTool", () => { | ||
| const mockContext: AgentContext = { | ||
| event: {} as any, | ||
| appId: 1, | ||
| appPath: "/test/app", | ||
| chatId: 1, | ||
| supabaseProjectId: null, | ||
| supabaseOrganizationSlug: null, | ||
| messageId: 1, | ||
| isSharedModulesChanged: false, | ||
| isDyadPro: false, | ||
| todos: [], | ||
| dyadRequestId: "test-request", | ||
| fileEditTracker: {}, | ||
| onXmlStream: vi.fn(), | ||
| onXmlComplete: vi.fn(), | ||
| requireConsent: vi.fn().mockResolvedValue(true), | ||
| appendUserMessage: vi.fn(), | ||
| onUpdateTodos: vi.fn(), | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe("schema validation", () => { | ||
| it("rejects empty path", () => { | ||
| const schema = deleteFileTool.inputSchema; | ||
| expect(() => schema.parse({ path: "" })).toThrow("Path cannot be empty"); | ||
| }); | ||
|
|
||
| it("rejects whitespace-only path", () => { | ||
| const schema = deleteFileTool.inputSchema; | ||
| expect(() => schema.parse({ path: " " })).toThrow( | ||
| "Path cannot be empty", | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("execute safety checks", () => { | ||
| it.each([".", "./", ".\\", "foo/..", "foo\\.."])( | ||
| "rejects project-root-equivalent path: %s", | ||
| async (path) => { | ||
| await expect( | ||
| deleteFileTool.execute({ path }, mockContext), | ||
| ).rejects.toThrow(/Refusing to delete project root/); | ||
|
|
||
| expect(fs.existsSync).not.toHaveBeenCalled(); | ||
| expect(fs.unlinkSync).not.toHaveBeenCalled(); | ||
| expect(fs.rmdirSync).not.toHaveBeenCalled(); | ||
| expect(gitRemove).not.toHaveBeenCalled(); | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| describe("execute delete behavior", () => { | ||
| it("deletes files with unlink and removes from git", async () => { | ||
| vi.mocked(fs.existsSync).mockReturnValue(true); | ||
| vi.mocked(fs.lstatSync).mockReturnValue({ | ||
| isDirectory: () => false, | ||
| } as any); | ||
|
|
||
| const result = await deleteFileTool.execute( | ||
| { path: "src/file.ts" }, | ||
| mockContext, | ||
| ); | ||
|
|
||
| expect(fs.unlinkSync).toHaveBeenCalledWith("/test/app/src/file.ts"); | ||
| expect(fs.rmdirSync).not.toHaveBeenCalled(); | ||
| expect(gitRemove).toHaveBeenCalledWith({ | ||
| path: "/test/app", | ||
| filepath: "src/file.ts", | ||
| }); | ||
| expect(result).toBe("Successfully deleted src/file.ts"); | ||
| }); | ||
|
|
||
| it("deletes directories with rmdir recursive", async () => { | ||
| vi.mocked(fs.existsSync).mockReturnValue(true); | ||
| vi.mocked(fs.lstatSync).mockReturnValue({ | ||
| isDirectory: () => true, | ||
| } as any); | ||
|
|
||
| const result = await deleteFileTool.execute( | ||
| { path: "src/dir" }, | ||
| mockContext, | ||
| ); | ||
|
|
||
| expect(fs.rmdirSync).toHaveBeenCalledWith("/test/app/src/dir", { | ||
| recursive: true, | ||
| }); | ||
| expect(fs.unlinkSync).not.toHaveBeenCalled(); | ||
| expect(result).toBe("Successfully deleted src/dir"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("buildXml", () => { | ||
| it("returns undefined for blank path", () => { | ||
| const result = deleteFileTool.buildXml?.({ path: " " }, false); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,7 +18,12 @@ function getFunctionNameFromPath(input: string): string { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const deleteFileSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: z.string().describe("The file path to delete"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: z | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .string() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .refine((value) => value.trim().length > 0, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: "Path cannot be empty", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .describe("The file path to delete"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -32,11 +37,24 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> = | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| getConsentPreview: (args) => `Delete ${args.path}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| buildXml: (args, _isComplete) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!args.path) return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!args.path?.trim()) return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<dyad-delete path="${escapeXmlAttr(args.path)}"></dyad-delete>`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| execute: async (args, ctx: AgentContext) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const normalizedPath = path.posix.normalize( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| args.path.replace(/\\/g, "/"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizedPath === "." || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizedPath === "./" || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizedPath === "" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The conditions normalizedPath === "."There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dead conditions after
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | security Parent-traversal path
Also: 💡 Suggestion: Simplify and strengthen the guard:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `Refusing to delete project root for path: "${args.path}"`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
44
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | correctness Guard and safeJoin use different normalization strategies The guard converts backslashes to A simpler, more robust approach would resolve the path once against
Suggested change
This uses the same native resolution that
Comment on lines
+48
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dead conditions after
The first condition (
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/pro/main/ipc/handlers/local_agent/tools/delete_file.ts
Line: 48-56
Comment:
**Dead conditions after `path.posix.normalize`**
`path.posix.normalize` never returns `"./"` or `""` — it always collapses those to `"."`. This means the second and third conditions in the guard are unreachable dead code:
- `normalizedPath === "./"` — `normalize("./")` returns `"."`, not `"./"`.
- `normalizedPath === ""` — `normalize("")` also returns `"."`, not `""`.
The first condition (`=== "."`) is the only one that actually fires, and it already covers both of those inputs. The tests pass because they're all caught by the `"."` branch before reaching the dead branches.
```suggestion
if (normalizedPath === ".") {
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fullFilePath = safeJoin(ctx.appPath, args.path); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Track if this is a shared module | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 MEDIUM | test-coverage
Test suite missing
..(bare parent traversal) caseThe
it.eacharray covers".","./",".\\","foo/..", and"foo\\.."but omits a bare"..". Sincepath.posix.normalize("..")returns".."(not"."), this input actually bypasses the root-path guard in the implementation — a gap already flagged by another reviewer on the implementation side.Adding
".."to this test list would make the gap visible in CI: the test would fail, documenting that the guard needs to handle parent-traversal paths too.💡 Suggestion: Add
".."to theit.eacharray so the test serves as an executable specification of the guard's contract.