Skip to content
6 changes: 6 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ export interface ExtensionMessage {
| "branchWorktreeIncludeResult"
| "folderSelected"
| "skills"
| "fileContent"
text?: string
/** For fileContent: { path, content, error? } */
fileContent?: { path: string; content: string | null; error?: string }
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
Expand Down Expand Up @@ -444,6 +447,7 @@ export interface WebviewMessage {
| "openImage"
| "saveImage"
| "openFile"
| "readFileContent"
| "openMention"
| "cancelTask"
| "cancelAutoApproval"
Expand Down Expand Up @@ -786,6 +790,8 @@ export interface ClineSayTool {
matchCount?: number
diff?: string
content?: string
// Original file content before first edit (for merged diff display in FileChangesPanel)
originalContent?: string
// Unified diff statistics computed by the extension
diffStats?: { added: number; removed: number }
regex?: string
Expand Down
2 changes: 2 additions & 0 deletions src/core/tools/ApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
...sharedMessageProps,
diff: diffContent,
content: unifiedPatch,
originalContent,
diffStats,
isProtected: isWriteProtected,
} satisfies ClineSayTool)
Expand Down Expand Up @@ -194,6 +195,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
...sharedMessageProps,
diff: diffContent,
content: unifiedPatch,
originalContent,
diffStats,
isProtected: isWriteProtected,
} satisfies ClineSayTool)
Expand Down
1 change: 1 addition & 0 deletions src/core/tools/ApplyPatchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
tool: "appliedDiff",
path: getReadablePath(task.cwd, relPath),
diff: sanitizedDiff,
originalContent,
isOutsideWorkspace,
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// npx vitest core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts

import { describe, it, expect, vi, beforeEach } from "vitest"

vi.mock("../../../api/providers/fetchers/modelCache")

vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
showTextDocument: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
openTextDocument: vi.fn().mockResolvedValue({}),
},
}))

vi.mock("../../../i18n", () => ({
t: vi.fn((key: string) => key),
}))

vi.mock("fs/promises", () => {
const readFile = vi.fn().mockResolvedValue("file content here")
return {
default: {
rm: vi.fn(),
mkdir: vi.fn(),
readFile,
writeFile: vi.fn(),
},
rm: vi.fn(),
mkdir: vi.fn(),
readFile,
writeFile: vi.fn(),
}
})

vi.mock("../../../utils/fs")
vi.mock("../../../utils/path")
vi.mock("../../../utils/globalContext")

vi.mock("../../../utils/pathUtils", () => ({
isPathOutsideWorkspace: vi.fn((filePath: string) => {
const nodePath = require("path")
const normalized = nodePath.resolve(filePath)
const workspaceRoot = nodePath.resolve("/mock/workspace")
// Path is inside workspace if it equals or is under workspace root
if (normalized === workspaceRoot) return false
if (normalized.startsWith(workspaceRoot + nodePath.sep)) return false
return true
}),
}))

vi.mock("../../mentions/resolveImageMentions", () => ({
resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({
text,
images: [...(images ?? [])],
})),
}))

import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import * as fs from "fs/promises"

const MOCK_CWD = "/mock/workspace/project"

const mockProvider = {
getState: vi.fn(),
postMessageToWebview: vi.fn(),
customModesManager: {
getCustomModes: vi.fn(),
deleteCustomMode: vi.fn(),
},
context: {
extensionPath: "/mock/extension/path",
globalStorageUri: { fsPath: "/mock/global/storage" },
},
contextProxy: {
context: {
extensionPath: "/mock/extension/path",
globalStorageUri: { fsPath: "/mock/global/storage" },
},
setValue: vi.fn(),
getValue: vi.fn(),
},
log: vi.fn(),
postStateToWebview: vi.fn(),
getCurrentTask: vi.fn().mockReturnValue({ cwd: MOCK_CWD }),
getTaskWithId: vi.fn(),
createTaskWithHistoryItem: vi.fn(),
cwd: MOCK_CWD,
} as unknown as ClineProvider

describe("webviewMessageHandler - readFileContent path traversal prevention", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fs.readFile).mockResolvedValue("file content here")
vi.mocked(mockProvider.getCurrentTask).mockReturnValue({ cwd: MOCK_CWD } as any)
})

it("allows reading a file within the workspace using a relative path", async () => {
await webviewMessageHandler(mockProvider, {
type: "readFileContent",
text: "src/index.ts",
})

expect(fs.readFile).toHaveBeenCalled()
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "fileContent",
fileContent: expect.objectContaining({
path: "src/index.ts",
content: "file content here",
}),
}),
)
})

it("blocks path traversal with ../", async () => {
await webviewMessageHandler(mockProvider, {
type: "readFileContent",
text: "../../../etc/passwd",
})

expect(fs.readFile).not.toHaveBeenCalled()
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "fileContent",
fileContent: expect.objectContaining({
path: "../../../etc/passwd",
content: null,
error: "Path is outside workspace",
}),
}),
)
})

it("blocks absolute paths outside the workspace", async () => {
await webviewMessageHandler(mockProvider, {
type: "readFileContent",
text: "/etc/shadow",
})

expect(fs.readFile).not.toHaveBeenCalled()
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "fileContent",
fileContent: expect.objectContaining({
path: "/etc/shadow",
content: null,
error: "Path is outside workspace",
}),
}),
)
})

it("blocks traversal disguised in the middle of a path", async () => {
await webviewMessageHandler(mockProvider, {
type: "readFileContent",
text: "src/../../../../etc/passwd",
})

expect(fs.readFile).not.toHaveBeenCalled()
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "fileContent",
fileContent: expect.objectContaining({
content: null,
error: "Path is outside workspace",
}),
}),
)
})

it("returns error when no path is provided", async () => {
await webviewMessageHandler(mockProvider, {
type: "readFileContent",
text: "",
})

expect(fs.readFile).not.toHaveBeenCalled()
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "fileContent",
fileContent: expect.objectContaining({
content: null,
error: "No path provided",
}),
}),
)
})

it("allows reading a file using an absolute path within the workspace", async () => {
await webviewMessageHandler(mockProvider, {
type: "readFileContent",
text: `${MOCK_CWD}/src/index.ts`,
})

expect(fs.readFile).toHaveBeenCalled()
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "fileContent",
fileContent: expect.objectContaining({
content: "file content here",
}),
}),
)
})
})
39 changes: 39 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { openMention } from "../mentions"
import { resolveImageMentions } from "../mentions/resolveImageMentions"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getWorkspacePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { GetModelsOptions } from "../../shared/api"
Expand Down Expand Up @@ -1142,6 +1143,44 @@ export const webviewMessageHandler = async (
}
openFile(filePath, message.values as { create?: boolean; content?: string; line?: number })
break
case "readFileContent": {
const relPath = message.text || ""
if (!relPath) {
provider.postMessageToWebview({
type: "fileContent",
fileContent: { path: relPath, content: null, error: "No path provided" },
})
break
}
try {
const cwd = getCurrentCwd()
if (!cwd) {
provider.postMessageToWebview({
type: "fileContent",
fileContent: { path: relPath, content: null, error: "No workspace path available" },
})
break
}
const absPath = path.resolve(cwd, relPath)
// Workspace-boundary validation: prevent path traversal attacks
if (isPathOutsideWorkspace(absPath)) {
provider.postMessageToWebview({
type: "fileContent",
fileContent: { path: relPath, content: null, error: "Path is outside workspace" },
})
break
}
const content = await fs.readFile(absPath, "utf-8")
provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content } })
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
provider.postMessageToWebview({
type: "fileContent",
fileContent: { path: relPath, content: null, error: errorMsg },
})
}
break
}
case "openMention":
openMention(getCurrentCwd(), message.text)
break
Expand Down
26 changes: 25 additions & 1 deletion webview-ui/src/__tests__/FileChangesPanel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ vi.mock("@src/components/common/CodeAccordian", () => ({
),
}))

function createFileEditMessage(path: string, diff: string): ClineMessage {
function createFileEditMessage(
path: string,
diff: string,
diffStats?: { added: number; removed: number },
): ClineMessage {
return {
type: "ask",
ask: "tool",
Expand All @@ -55,6 +59,7 @@ function createFileEditMessage(path: string, diff: string): ClineMessage {
tool: "appliedDiff",
path,
diff,
...(diffStats && { diffStats }),
}),
}
}
Expand Down Expand Up @@ -172,4 +177,23 @@ describe("FileChangesPanel", () => {
fireEvent.click(accordianToggle)
expect(accordianToggle).toHaveTextContent("expanded")
})

it("hides aggregate stats when no diffStats are present", () => {
const messages = [createFileEditMessage("src/a.ts", "diff a"), createFileEditMessage("src/b.ts", "diff b")]
renderPanel(messages)

expect(screen.queryByTestId("total-added")).not.toBeInTheDocument()
expect(screen.queryByTestId("total-removed")).not.toBeInTheDocument()
})

it("shows aggregated + and - totals in the header when diffStats are present", () => {
const messages = [
createFileEditMessage("src/a.ts", "diff a", { added: 3, removed: 1 }),
createFileEditMessage("src/b.ts", "diff b", { added: 2, removed: 5 }),
]
renderPanel(messages)

expect(screen.getByTestId("total-added")).toHaveTextContent("+5")
expect(screen.getByTestId("total-removed")).toHaveTextContent("-6")
})
})
Loading
Loading