Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions rules/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ gh api graphql --input .claude/tmp/resolve_thread.json
gh api repos/dyad-sh/dyad/issues/{PR_NUMBER}/labels -f "labels[]=label-name"
```

## Removing labels with special characters

The security hook blocks `gh api` DELETE calls when the URL path contains colons (e.g., `needs-human:review-issue`) or URL-encoded colons (`%3A`). Both literal colons and percent-encoded forms trigger the "shell metacharacters" block. Currently there is no workaround within the sandbox — skip these label removals if they are non-critical.

## CI file access (claude-code-action)

In CI, `claude-code-action` restricts file access to the repo working directory (e.g., `/home/runner/work/dyad/dyad`). Skills that save intermediate files (like PR diffs) must use `./filename` (current working directory), **never** `/tmp/`. Using `/tmp/` causes errors like: `cat in '/tmp/pr_*_diff.patch' was blocked. For security, Claude Code may only concatenate files from the allowed working directories`.
Expand Down
140 changes: 140 additions & 0 deletions src/pro/main/ipc/handlers/local_agent/tools/delete_file.spec.ts
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();
},
);
Comment on lines +85 to +91
Copy link
Copy Markdown
Contributor

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) case

The it.each array covers ".", "./", ".\\", "foo/..", and "foo\\.." but omits a bare "..". Since path.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 the it.each array so the test serves as an executable specification of the guard's contract.

});

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();
});
});
});
22 changes: 20 additions & 2 deletions src/pro/main/ipc/handlers/local_agent/tools/delete_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>> =
Expand All @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The conditions normalizedPath === "./" and normalizedPath === "" are redundant because path.posix.normalize() converts an empty string or './' into '.'. Simplifying this security-related check to just normalizedPath === "." will make the code cleaner and easier to maintain without altering its behavior.

        normalizedPath === "."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead conditions after path.posix.normalize call

Low Severity

The conditions normalizedPath === "./" and normalizedPath === "" are unreachable dead code. path.posix.normalize never returns "./" (it strips trailing slashes, returning "." instead) and never returns "" (it converts empty strings to "."). Both inputs of concern already collapse to ".", which is correctly caught by the preceding normalizedPath === "." check. The dead branches create a false impression that these cases require separate handling.

Fix in Cursor Fix in Web

) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM | security

Parent-traversal path .. bypasses the root-deletion guard

path.posix.normalize('..') returns '..', not '.', so a bare .. path passes all three conditions ('.', './', '') and falls through to safeJoin. While safeJoin will ultimately throw for .. (preventing actual deletion), the guard's purpose is to provide a clear "Refusing to delete project root" error. The current code gives a different, less descriptive error from safeJoin instead.

Also: path.posix.normalize never returns './' or '' — it normalizes both to '.'. These two branches are unreachable dead code.

💡 Suggestion: Simplify and strengthen the guard:

Suggested change
) {
if (
normalizedPath === "." ||
normalizedPath === ".." ||
normalizedPath.startsWith("../")
) {

throw new Error(
`Refusing to delete project root for path: "${args.path}"`,
);
}
Comment on lines 44 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 / and uses path.posix.normalize, but safeJoin (called on line 58 with the original args.path) uses OS-native path.resolve. These two layers reason about paths using different normalization semantics, which makes the guard fragile and hard to audit.

A simpler, more robust approach would resolve the path once against ctx.appPath and compare:

Suggested change
execute: async (args, ctx: AgentContext) => {
const normalizedPath = path.posix.normalize(
args.path.replace(/\\/g, "/"),
);
if (
normalizedPath === "." ||
normalizedPath === "./" ||
normalizedPath === ""
) {
throw new Error(
`Refusing to delete project root for path: "${args.path}"`,
);
}
const resolvedPath = path.resolve(ctx.appPath, args.path);
if (resolvedPath === path.resolve(ctx.appPath)) {
throw new Error(
`Refusing to delete project root for path: "${args.path}"`,
);
}

This uses the same native resolution that safeJoin relies on, making the guard's semantics consistent with the actual deletion path.

Comment on lines +48 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (
normalizedPath === "." ||
normalizedPath === "./" ||
normalizedPath === ""
) {
throw new Error(
`Refusing to delete project root for path: "${args.path}"`,
);
}
if (normalizedPath === ".") {
Prompt To Fix With AI
This 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
Expand Down
Loading