Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
"node-addon-api": "^7.1.0",
"node-pty": "1.1.0",
"os-locale": "^6.0.2",
"pathe": "^2.0.3",
"pidtree": "^0.6.0",
"pidusage": "^4.0.1",
"posthog-js": "1.310.1",
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/lib/trpc/routers/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,60 @@ export const createFilesystemRouter = () => {
return { copied, errors };
}),

/**
* Read a file by absolute path with size cap and binary detection.
* Used for viewing files outside the current worktree.
*/
readFile: publicProcedure
.input(
z.object({
filePath: z.string(),
}),
)
.query(
async ({
input,
}): Promise<
| {
ok: true;
content: string;
truncated: boolean;
byteLength: number;
}
| {
ok: false;
reason: "not-found" | "too-large" | "binary";
}
> => {
const MAX_FILE_SIZE = 2 * 1024 * 1024;
try {
const stats = await fs.stat(input.filePath);
if (stats.size > MAX_FILE_SIZE) {
return { ok: false, reason: "too-large" };
}

const buffer = await fs.readFile(input.filePath);
Comment on lines +935 to +940
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.

⚠️ Potential issue | 🔴 Critical

Reject non-regular files before fs.readFile().

The size check alone is not a safe guard here. FIFOs, device nodes, and other special files can report small or zero sizes, and fs.readFile() can then block or blow past the 2 MB policy. Bail out unless stats.isFile() is true.

🔒 Suggested fix
 						const stats = await fs.stat(input.filePath);
+						if (!stats.isFile()) {
+							return { ok: false, reason: "not-found" };
+						}
 						if (stats.size > MAX_FILE_SIZE) {
 							return { ok: false, reason: "too-large" };
 						}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const stats = await fs.stat(input.filePath);
if (stats.size > MAX_FILE_SIZE) {
return { ok: false, reason: "too-large" };
}
const buffer = await fs.readFile(input.filePath);
const stats = await fs.stat(input.filePath);
if (!stats.isFile()) {
return { ok: false, reason: "not-found" };
}
if (stats.size > MAX_FILE_SIZE) {
return { ok: false, reason: "too-large" };
}
const buffer = await fs.readFile(input.filePath);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/filesystem/index.ts` around lines 935 -
940, The current guard only checks stats.size before calling fs.readFile, which
can still allow FIFOs or device nodes; update the logic around fs.stat/
fs.readFile to first reject non-regular files by verifying stats.isFile() is
true (using the stats object returned from fs.stat for input.filePath), and only
then check stats.size against MAX_FILE_SIZE and proceed to call fs.readFile;
ensure the function returns { ok: false, reason: "not-file" } (or similar) for
non-regular files and keeps the existing { ok: false, reason: "too-large" }
behavior for oversized regular files.


// Binary detection (same as readWorkingFile)
const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE);
for (let i = 0; i < checkLength; i++) {
if (buffer[i] === 0) {
return { ok: false, reason: "binary" };
}
Comment on lines +943 to +947
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 5, 2026

Choose a reason for hiding this comment

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

P2: Duplicate binary detection logic — reuse the existing isBinaryContent helper defined in this same file (line 292) instead of inlining the check.

(Based on your team's feedback about avoiding duplicating business logic in multiple components.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/lib/trpc/routers/filesystem/index.ts, line 982:

<comment>Duplicate binary detection logic — reuse the existing `isBinaryContent` helper defined in this same file (line 292) instead of inlining the check.

(Based on your team's feedback about avoiding duplicating business logic in multiple components.) </comment>

<file context>
@@ -944,6 +944,60 @@ export const createFilesystemRouter = () => {
+						const buffer = await fs.readFile(input.filePath);
+
+						// Binary detection (same as readWorkingFile)
+						const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE);
+						for (let i = 0; i < checkLength; i++) {
+							if (buffer[i] === 0) {
</file context>
Suggested change
const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE);
for (let i = 0; i < checkLength; i++) {
if (buffer[i] === 0) {
return { ok: false, reason: "binary" };
}
if (isBinaryContent(buffer)) {
return { ok: false, reason: "binary" };
}
Fix with Cubic

}

return {
ok: true,
content: buffer.toString("utf-8"),
truncated: false,
byteLength: buffer.length,
};
} catch {
return { ok: false, reason: "not-found" };
Comment on lines +956 to +957
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 5, 2026

Choose a reason for hiding this comment

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

P2: Catch-all block maps every error to "not-found", hiding the real cause (e.g. EACCES, EISDIR). At minimum, check err.code === 'ENOENT' before falling back, and log non-ENOENT errors so failures are diagnosable.

(Based on your team's feedback about avoiding empty catch blocks that hide failures.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/lib/trpc/routers/filesystem/index.ts, line 995:

<comment>Catch-all block maps every error to `"not-found"`, hiding the real cause (e.g. `EACCES`, `EISDIR`). At minimum, check `err.code === 'ENOENT'` before falling back, and log non-ENOENT errors so failures are diagnosable.

(Based on your team's feedback about avoiding empty catch blocks that hide failures.) </comment>

<file context>
@@ -944,6 +944,60 @@ export const createFilesystemRouter = () => {
+							truncated: false,
+							byteLength: buffer.length,
+						};
+					} catch {
+						return { ok: false, reason: "not-found" };
+					}
</file context>
Suggested change
} catch {
return { ok: false, reason: "not-found" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
console.error("[filesystem/readFile] Failed:", { path: input.filePath, error: err });
}
return { ok: false, reason: "not-found" };
Fix with Cubic

}
},
),
Comment on lines +912 to +960
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.

⚠️ Potential issue | 🔴 Critical

filesystem.readFile opens unrestricted host-file reads over public IPC.

This procedure accepts any renderer-supplied path and reads it directly, bypassing the worktree/path validation model used by secure file reads (changes.readWorkingFile + secureFs). That materially weakens the filesystem security boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/filesystem/index.ts` around lines 951 -
999, The publicProcedure readFile currently reads any host path (readFile)
directly, bypassing the project's path/worktree validation and opening an
unrestricted IPC file-read. Fix by routing this RPC through the same validated
codepath used elsewhere: call the existing secure read logic (e.g., reuse
changes.readWorkingFile or the secureFs path validation helper) before
performing fs.stat/fs.readFile, ensuring the input.filePath is validated as
inside the worktree/root and normalized; preserve the original return shape
(ok:true/false, reasons "not-found"|"too-large"|"binary") and retain the
BINARY_CHECK_SIZE and MAX_FILE_SIZE checks but only after the path validation
succeeds. Ensure no direct fs.readFile on arbitrary input.filePath remains in
readFile.


exists: publicProcedure
.input(z.object({ path: z.string() }))
.query(async ({ input }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function CommandPalette({
isLoading={isLoading}
results={searchResults}
getItemValue={(file) => `${file.path} ${query}`}
onSelectItem={(file) => onSelectFile(file.relativePath)}
onSelectItem={(file) => onSelectFile(file.path)}
renderItem={(file) => {
const { icon: Icon, color } = getFileIcon(file.name, false);
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function useKeywordSearch({
const selectMatch = useCallback(
(match: KeywordSearchResult) => {
useTabsStore.getState().addFileViewerPane(workspaceId, {
filePath: match.relativePath,
filePath: match.path,
line: match.line,
column: match.column,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useState } from "react";
import { useTabsStore } from "renderer/stores/tabs/store";
import { resolveToAbsolutePath } from "../../../../../../ChatPane/ChatInterface/utils/file-paths";
import type {
UserMessageActionPayload,
UserMessageRestartRequest,
Expand Down Expand Up @@ -58,9 +59,17 @@ export function UserMessage({
);
const openMentionedFile = useCallback(
(filePath: string) => {
addFileViewerPane(workspaceId, { filePath, isPinned: true });
const absolutePath = resolveToAbsolutePath({
filePath,
workspaceRoot: workspaceCwd,
});
if (!absolutePath) return;
addFileViewerPane(workspaceId, {
filePath: absolutePath,
isPinned: true,
});
},
[addFileViewerPane, workspaceId],
[addFileViewerPane, workspaceCwd, workspaceId],
);
const handleCopy = useCallback(() => {
if (!fullText) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { normalizeWorkspaceFilePath } from "../../../../../../../../ChatPane/ChatInterface/utils/file-paths";
import { resolveToAbsolutePath } from "../../../../../../../../ChatPane/ChatInterface/utils/file-paths";
import type { MastraMessage, MastraMessagePart } from "../../types";
import { parseUserMentions } from "../../utils/parseUserMentions";

Expand Down Expand Up @@ -36,20 +36,20 @@ export function UserMessageText({
);
}

const normalizedPath = normalizeWorkspaceFilePath({
const absolutePath = resolveToAbsolutePath({
filePath: segment.relativePath,
workspaceRoot: workspaceCwd,
});
const canOpen = Boolean(normalizedPath);
const canOpen = Boolean(absolutePath);

return (
<button
type="button"
key={`${message.id}-${partIndex}-${segmentIndex}`}
className="mx-0.5 inline-flex items-center gap-0.5 rounded-md bg-primary/15 px-1.5 py-0.5 font-mono text-xs text-primary transition-colors hover:bg-primary/22 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-default disabled:opacity-60"
onClick={() => {
if (!normalizedPath) return;
onOpenMentionedFile(normalizedPath);
if (!absolutePath) return;
onOpenMentionedFile(absolutePath);
}}
disabled={!canOpen}
aria-label={`Open file ${segment.relativePath}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useChangesStore } from "renderer/stores/changes";
import { useTabsStore } from "renderer/stores/tabs/store";
import type { ChangeCategory } from "shared/changes-types";
import { READ_ONLY_TOOLS } from "../../constants";
import { normalizeWorkspaceFilePath } from "../../utils/file-paths";
import { resolveToAbsolutePath } from "../../utils/file-paths";
import type { ToolPart } from "../../utils/tool-helpers";
import {
getArgs,
Expand Down Expand Up @@ -83,22 +83,21 @@ export function MastraToolCallBlock({
const toolDisplayName = toolName
.replace("mastra_workspace_", "")
.replaceAll("_", " ");
const normalizeFilePath = useCallback(
const resolveFilePath = useCallback(
(filePath: string) => {
const normalizedPath = normalizeWorkspaceFilePath({
return resolveToAbsolutePath({
filePath,
workspaceRoot: workspaceCwd,
});
return normalizedPath ?? null;
},
[workspaceCwd],
);
const openFileInPane = useCallback(
(filePath: string) => {
if (!workspaceId) return;
const normalizedPath = normalizeFilePath(filePath);
if (!normalizedPath) return;
addFileViewerPane(workspaceId, { filePath: normalizedPath });
const absolutePath = resolveFilePath(filePath);
if (!absolutePath) return;
addFileViewerPane(workspaceId, { filePath: absolutePath });
posthog.capture("chat_file_opened_from_tool", {
workspace_id: workspaceId,
session_id: sessionId ?? null,
Expand All @@ -109,7 +108,7 @@ export function MastraToolCallBlock({
},
[
addFileViewerPane,
normalizeFilePath,
resolveFilePath,
organizationId,
sessionId,
toolName,
Expand Down Expand Up @@ -145,21 +144,21 @@ export function MastraToolCallBlock({
}, [panes, tabs, workspaceId]);
const getDiffPaneTargetForFile = useCallback(
(filePath: string) => {
const normalizedPath = normalizeFilePath(filePath);
if (!normalizedPath) return null;
return workspaceDiffPaneByFilePath.get(normalizedPath) ?? null;
const absolutePath = resolveFilePath(filePath);
if (!absolutePath) return null;
return workspaceDiffPaneByFilePath.get(absolutePath) ?? null;
},
[normalizeFilePath, workspaceDiffPaneByFilePath],
[resolveFilePath, workspaceDiffPaneByFilePath],
);
const openFileInDiffPane = useCallback(
(filePath: string) => {
if (!workspaceId) return;
const normalizedPath = normalizeFilePath(filePath);
const absolutePath = resolveFilePath(filePath);
const diffPaneTarget = getDiffPaneTargetForFile(filePath);
if (!normalizedPath) return;
if (!absolutePath) return;

addFileViewerPane(workspaceId, {
filePath: normalizedPath,
filePath: absolutePath,
diffCategory: diffPaneTarget?.diffCategory ?? "unstaged",
commitHash: diffPaneTarget?.commitHash,
oldPath: diffPaneTarget?.oldPath,
Expand All @@ -176,7 +175,7 @@ export function MastraToolCallBlock({
[
addFileViewerPane,
getDiffPaneTargetForFile,
normalizeFilePath,
resolveFilePath,
organizationId,
sessionId,
toolName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useTabsStore } from "renderer/stores/tabs/store";
import { READ_ONLY_TOOLS } from "../../constants";
import {
getWorkspaceToolFilePath,
normalizeWorkspaceFilePath,
resolveToAbsolutePath,
} from "../../utils/file-paths";
import type { ToolPart } from "../../utils/tool-helpers";
import { getArgs, normalizeToolName } from "../../utils/tool-helpers";
Expand Down Expand Up @@ -63,12 +63,12 @@ export function MessagePartsRenderer({
const openFileInPane = useCallback(
(filePath: string) => {
if (!workspaceId) return;
const normalizedPath = normalizeWorkspaceFilePath({
const absolutePath = resolveToAbsolutePath({
filePath,
workspaceRoot: workspaceCwd,
});
if (!normalizedPath) return;
addFileViewerPane(workspaceId, { filePath: normalizedPath });
if (!absolutePath) return;
addFileViewerPane(workspaceId, { filePath: absolutePath });
},
[addFileViewerPane, workspaceCwd, workspaceId],
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isAbsolute, normalize, relative, resolve } from "pathe";

export function getWorkspaceToolFilePath({
toolName,
args,
Expand Down Expand Up @@ -26,39 +28,74 @@ export function normalizeWorkspaceFilePath({
filePath: string;
workspaceRoot?: string;
}): string | null {
let normalizedPath = filePath.trim();
let normalizedPath = stripFileUri(filePath.trim());
if (!normalizedPath) return null;

if (normalizedPath.startsWith("file://")) {
const rawPath = normalizedPath.slice(7);
try {
normalizedPath = decodeURIComponent(rawPath);
} catch {
normalizedPath = rawPath;
normalizedPath = normalize(normalizedPath);

if (workspaceRoot) {
const root = normalize(workspaceRoot);
if (normalizedPath === root) return null;
if (isAbsolute(normalizedPath)) {
const rel = relative(root, normalizedPath);
// relative() returns a path starting with ".." if outside the root
if (rel.startsWith("..")) return null;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 5, 2026

Choose a reason for hiding this comment

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

P2: The rel.startsWith("..") guard is too broad and can reject valid files under the workspace (e.g. child directories named ..config).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/file-paths.ts, line 42:

<comment>The `rel.startsWith("..")` guard is too broad and can reject valid files under the workspace (e.g. child directories named `..config`).</comment>

<file context>
@@ -26,37 +28,24 @@ export function normalizeWorkspaceFilePath({
+		if (isAbsolute(normalizedPath)) {
+			const rel = relative(root, normalizedPath);
+			// relative() returns a path starting with ".." if outside the root
+			if (rel.startsWith("..")) return null;
+			normalizedPath = rel;
 		}
</file context>
Suggested change
if (rel.startsWith("..")) return null;
if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return null;
Fix with Cubic

normalizedPath = rel;
}
}

normalizedPath = normalizedPath.replaceAll("\\", "/");
if (!normalizedPath || normalizedPath === ".") return null;
if (isAbsolute(normalizedPath)) return null;

const normalizedRoot = workspaceRoot
? workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, "")
: "";
return normalizedPath;
}

if (normalizedRoot) {
if (normalizedPath === normalizedRoot) return null;
if (normalizedPath.startsWith(`${normalizedRoot}/`)) {
normalizedPath = normalizedPath.slice(normalizedRoot.length + 1);
}
/**
* Resolves a file path to an absolute path given a workspace root.
* Handles file:// URIs, relative paths, and already-absolute paths.
* Returns null if the path is empty or resolves to the workspace root itself.
*/
export function resolveToAbsolutePath({
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
filePath,
workspaceRoot,
}: {
filePath: string;
workspaceRoot?: string;
}): string | null {
const normalizedPath = stripFileUri(filePath.trim());
if (!normalizedPath) return null;

// Remote URL — pass through
if (
normalizedPath.startsWith("https://") ||
normalizedPath.startsWith("http://")
) {
return normalizedPath;
}

while (normalizedPath.startsWith("./")) {
normalizedPath = normalizedPath.slice(2);
// Already absolute — normalize and return
if (isAbsolute(normalizedPath)) {
return normalize(normalizedPath);
}

if (!normalizedPath || normalizedPath === ".") return null;
if (normalizedPath.startsWith("/")) return null;
// Relative path — resolve against workspace root
if (!workspaceRoot) return null;

return normalizedPath;
const resolved = resolve(workspaceRoot, normalizedPath);
// Don't return the workspace root itself
if (resolved === normalize(workspaceRoot)) return null;

return resolved;
}

function stripFileUri(path: string): string {
if (!path.startsWith("file://")) return path;
const rawPath = path.slice(7);
try {
return decodeURIComponent(rawPath);
} catch {
return rawPath;
}
}

function toStringValue(value: unknown): string | null {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { basename, relative } from "pathe";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MosaicBranch } from "react-mosaic-component";
import { useChangesStore } from "renderer/stores/changes";
Expand Down Expand Up @@ -244,7 +245,13 @@ export function FileViewerPane({
pendingModeRef.current = null;
};

const fileName = filePath.split("/").pop() || filePath;
const fileName = basename(filePath) || filePath;
// Derive display path: relative for in-worktree files, absolute otherwise
const displayPath = (() => {
if (!worktreePath || !filePath.startsWith(worktreePath)) return filePath;
const rel = relative(worktreePath, filePath);
return rel.startsWith("..") ? filePath : rel;
})();
const hasRenderedMode = isMarkdownFile(filePath) || isImageFile(filePath);
const hasDiff = !!diffCategory;

Expand All @@ -262,7 +269,7 @@ export function FileViewerPane({
<div className="flex h-full w-full">
<FileViewerToolbar
fileName={fileName}
filePath={filePath}
filePath={displayPath}
isDirty={isDirty}
viewMode={viewMode}
isPinned={isPinned}
Expand All @@ -284,6 +291,7 @@ export function FileViewerPane({
<FileViewerContent
viewMode={viewMode}
filePath={filePath}
displayPath={displayPath}
isLoadingRaw={isLoadingRaw}
isLoadingImage={isLoadingImage}
isLoadingDiff={isLoadingDiff}
Expand Down
Loading