Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b35f263
feat(desktop): add Workbench/Review mode with FileViewer panes
andreasasprou Dec 29, 2025
0d91d55
fix(desktop): add worktreePath guards and fix stale state in tabs store
andreasasprou Dec 29, 2025
530a423
fix(desktop): address CodeRabbit review - path traversal security fix…
andreasasprou Dec 29, 2025
676363d
fix(desktop): address PR review - security fixes and UX improvements
andreasasprou Dec 29, 2025
e8e2a19
fix(desktop): address review - persistence schema and security fixes
andreasasprou Dec 29, 2025
e06731e
fix(desktop): address critical review feedback - security and UX fixes
andreasasprou Dec 29, 2025
e9f4f2e
fix(desktop): critical security fix - validate worktreePath against l…
andreasasprou Dec 29, 2025
927d040
fix(desktop): security - validate worktreePath in all routes + preser…
andreasasprou Dec 29, 2025
31eb9cf
fix(desktop): security - path traversal in deleteUntracked + draft pr…
andreasasprou Dec 30, 2025
b2931b6
refactor(desktop): simplify security code - remove overcomplicated pa…
andreasasprou Dec 30, 2025
bc7dd58
fix(desktop): P0 untracked linecount + P2 terminal guard + branch safety
andreasasprou Dec 30, 2025
6d86a00
refactor(desktop): security architecture overhaul with proper path va…
andreasasprou Dec 30, 2025
d70d6b3
feat(desktop): add configurable terminal file link behavior
andreasasprou Dec 31, 2025
0bfdbe6
refactor(desktop): simplify security - remove symlink escape detection
andreasasprou Dec 31, 2025
3d0234b
feat(desktop): add configurable workspace navigation style (top-bar v…
andreasasprou Dec 31, 2025
dcd6125
fix(desktop): P0 CreateWorkspaceButton in sidebar mode + P1 race cond…
andreasasprou Dec 31, 2025
0627835
feat(desktop): workspace sidebar 1-1 feature parity with top-bar tabs
andreasasprou Dec 31, 2025
c721e57
refactor(desktop): extract shared utilities for workspace components
andreasasprou Dec 31, 2025
4e1711d
refactor(desktop): move sidebar toggle to WorkspaceActionBar in sideb…
andreasasprou Dec 31, 2025
36aff25
fix(desktop): prevent TopBar overlap with Mac traffic lights during load
andreasasprou Dec 31, 2025
aa12043
fix(desktop): increase Mac traffic light padding for better spacing
andreasasprou Dec 31, 2025
484be79
fix(desktop): address PR review feedback for changes security and ter…
andreasasprou Dec 31, 2025
e6ab981
feat(desktop): add 'Mark as Unread' context menu option for workspaces
andreasasprou Dec 31, 2025
14294ec
fix(desktop): clear workspace attention when clicking workspace
andreasasprou Dec 31, 2025
2dcad38
fix(desktop): address second round of PR review feedback
andreasasprou Dec 31, 2025
d4e1456
fix(desktop): address third round of PR review feedback
andreasasprou Dec 31, 2025
94f6116
Merge branch 'superset-sh:main' into workspace-sidebar
andreasasprou Jan 2, 2026
e1c6cca
feat(desktop): merge workspace action bar into top bar
andreasasprou Jan 2, 2026
e3111f2
fix(desktop): always show close/delete dialog for workspaces
andreasasprou Jan 2, 2026
303a158
fix(desktop): resolve TypeScript errors in workspace-sidebar branch
andreasasprou Jan 2, 2026
45f4741
style(desktop): use border instead of bg for active tab in GroupStrip
andreasasprou Jan 2, 2026
9d168c9
style(desktop): use top/side borders for active tab in GroupStrip
andreasasprou Jan 2, 2026
96fa309
fix(desktop): update sidebar toggle tooltip to 'Toggle Changes Sidebar'
andreasasprou Jan 2, 2026
ed7a9d0
feat(desktop): add sidebar toggle to TabsContent in sidebar navigatio…
andreasasprou Jan 2, 2026
166be33
fix(desktop): lift SidebarControl to ContentView to fix review mode r…
andreasasprou Jan 2, 2026
936dd58
refactor(desktop): rename NEW_TERMINAL hotkey to NEW_GROUP for clarity
andreasasprou Jan 2, 2026
6ced48d
feat(desktop): move workspace controls to content header in sidebar mode
andreasasprou Jan 3, 2026
1f988d8
fix(desktop): harden file viewer security and improve terminal link h…
andreasasprou Jan 3, 2026
ab1d0f6
fix(desktop): address PR review security and UX issues
andreasasprou Jan 3, 2026
c791f54
fix(desktop): use sep-aware check in assertParentInWorktree ENOENT path
andreasasprou Jan 3, 2026
1a2988e
fix(desktop): address P0/P1 security issues from review
andreasasprou Jan 3, 2026
930c122
fix(desktop): guard against undefined panes in attention check
andreasasprou Jan 3, 2026
edc4dae
fix(desktop): use strict allowlist for SafeImage - only allow data: URLs
andreasasprou Jan 3, 2026
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
271 changes: 271 additions & 0 deletions .agents/commands/create-plan-file.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions apps/desktop/src/lib/trpc/routers/changes/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { localDb } from "main/lib/local-db";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
assertRegisteredWorktree,
getRegisteredWorktree,
gitSwitchBranch,
} from "./security";

export const createBranchesRouter = () => {
return router({
Expand All @@ -18,6 +23,8 @@ export const createBranchesRouter = () => {
defaultBranch: string;
checkedOutBranches: Record<string, string>;
}> => {
assertRegisteredWorktree(input.worktreePath);

const git = simpleGit(input.worktreePath);

const branchSummary = await git.branch(["-a"]);
Expand Down Expand Up @@ -59,18 +66,11 @@ export const createBranchesRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);

const worktree = localDb
.select()
.from(worktrees)
.where(eq(worktrees.path, input.worktreePath))
.get();
if (!worktree) {
throw new Error(`No worktree found at path "${input.worktreePath}"`);
}
// Get worktree record for updating branch info
const worktree = getRegisteredWorktree(input.worktreePath);

await git.checkout(input.branch);
// Use gitSwitchBranch which uses `git switch` (correct branch syntax)
await gitSwitchBranch(input.worktreePath, input.branch);

// Update the branch in the worktree record
const gitStatus = worktree.gitStatus
Expand Down
204 changes: 146 additions & 58 deletions apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,48 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FileContents } from "shared/changes-types";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
assertRegisteredWorktree,
PathValidationError,
secureFs,
} from "./security";
import { detectLanguage } from "./utils/parse-status";

/** Maximum file size for reading (2 MiB) */
const MAX_FILE_SIZE = 2 * 1024 * 1024;

/** Bytes to scan for binary detection */
const BINARY_CHECK_SIZE = 8192;

/**
* Result type for readWorkingFile procedure
*/
type ReadWorkingFileResult =
| { ok: true; content: string; truncated: boolean; byteLength: number }
| {
ok: false;
reason:
| "not-found"
| "too-large"
| "binary"
| "outside-worktree"
| "symlink-escape";
};

/**
* Detects if a buffer contains binary content by checking for NUL bytes
*/
function isBinaryContent(buffer: Buffer): boolean {
const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE);
for (let i = 0; i < checkLength; i++) {
if (buffer[i] === 0) {
return true;
}
}
return false;
}

export const createFileContentsRouter = () => {
return router({
getFileContents: publicProcedure
Expand All @@ -20,6 +57,8 @@ export const createFileContentsRouter = () => {
}),
)
.query(async ({ input }): Promise<FileContents> => {
assertRegisteredWorktree(input.worktreePath);

const git = simpleGit(input.worktreePath);
const defaultBranch = input.defaultBranch || "main";
const originalPath = input.oldPath || input.filePath;
Expand Down Expand Up @@ -50,10 +89,63 @@ export const createFileContentsRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const fullPath = join(input.worktreePath, input.filePath);
await writeFile(fullPath, input.content, "utf-8");
// secureFs.writeFile validates worktree registration and path traversal
await secureFs.writeFile(
input.worktreePath,
input.filePath,
input.content,
);
return { success: true };
}),

/**
* Read a working tree file safely with size cap and binary detection.
* Used for File Viewer raw/rendered modes.
*/
readWorkingFile: publicProcedure
.input(
z.object({
worktreePath: z.string(),
filePath: z.string(),
}),
)
.query(async ({ input }): Promise<ReadWorkingFileResult> => {
try {
// Check file size first (uses stat which follows symlinks)
const stats = await secureFs.stat(input.worktreePath, input.filePath);
if (stats.size > MAX_FILE_SIZE) {
return { ok: false, reason: "too-large" };
}

// Read file content as buffer for binary detection
const buffer = await secureFs.readFileBuffer(
input.worktreePath,
input.filePath,
);

// Check for binary content
if (isBinaryContent(buffer)) {
return { ok: false, reason: "binary" };
}

return {
ok: true,
content: buffer.toString("utf-8"),
truncated: false,
byteLength: buffer.length,
};
} catch (error) {
if (error instanceof PathValidationError) {
// Map specific error codes to distinct reasons
if (error.code === "SYMLINK_ESCAPE") {
return { ok: false, reason: "symlink-escape" };
}
return { ok: false, reason: "outside-worktree" };
}
// File not found or other read error
return { ok: false, reason: "not-found" };
}
}),
});
};

Expand Down Expand Up @@ -91,26 +183,41 @@ async function getFileVersions(
}
}

/** Helper to safely get git show content with size limit and memory protection */
async function safeGitShow(
git: ReturnType<typeof simpleGit>,
spec: string,
): Promise<string> {
try {
// Preflight: check blob size before loading into memory
// This prevents memory spikes from large files in git history
try {
const sizeOutput = await git.raw(["cat-file", "-s", spec]);
const blobSize = Number.parseInt(sizeOutput.trim(), 10);
if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) {
return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`;
}
} catch {
// cat-file failed (blob doesn't exist) - let git.show handle the error
}

const content = await git.show([spec]);
return content;
} catch {
return "";
}
}

async function getAgainstBaseVersions(
git: ReturnType<typeof simpleGit>,
filePath: string,
originalPath: string,
defaultBranch: string,
): Promise<FileVersions> {
let original = "";
let modified = "";

try {
original = await git.show([`origin/${defaultBranch}:${originalPath}`]);
} catch {
original = "";
}

try {
modified = await git.show([`HEAD:${filePath}`]);
} catch {
modified = "";
}
const [original, modified] = await Promise.all([
safeGitShow(git, `origin/${defaultBranch}:${originalPath}`),
safeGitShow(git, `HEAD:${filePath}`),
]);

return { original, modified };
}
Expand All @@ -121,20 +228,10 @@ async function getCommittedVersions(
originalPath: string,
commitHash: string,
): Promise<FileVersions> {
let original = "";
let modified = "";

try {
original = await git.show([`${commitHash}^:${originalPath}`]);
} catch {
original = "";
}

try {
modified = await git.show([`${commitHash}:${filePath}`]);
} catch {
modified = "";
}
const [original, modified] = await Promise.all([
safeGitShow(git, `${commitHash}^:${originalPath}`),
safeGitShow(git, `${commitHash}:${filePath}`),
]);

return { original, modified };
}
Expand All @@ -144,20 +241,10 @@ async function getStagedVersions(
filePath: string,
originalPath: string,
): Promise<FileVersions> {
let original = "";
let modified = "";

try {
original = await git.show([`HEAD:${originalPath}`]);
} catch {
original = "";
}

try {
modified = await git.show([`:0:${filePath}`]);
} catch {
modified = "";
}
const [original, modified] = await Promise.all([
safeGitShow(git, `HEAD:${originalPath}`),
safeGitShow(git, `:0:${filePath}`),
]);

return { original, modified };
}
Expand All @@ -168,22 +255,23 @@ async function getUnstagedVersions(
filePath: string,
originalPath: string,
): Promise<FileVersions> {
let original = "";
let modified = "";

try {
original = await git.show([`:0:${originalPath}`]);
} catch {
try {
original = await git.show([`HEAD:${originalPath}`]);
} catch {
original = "";
}
// Try staged version first, fall back to HEAD
let original = await safeGitShow(git, `:0:${originalPath}`);
if (!original) {
original = await safeGitShow(git, `HEAD:${originalPath}`);
}

let modified = "";
try {
modified = await readFile(join(worktreePath, filePath), "utf-8");
// Check file size before reading (uses stat which follows symlinks)
const stats = await secureFs.stat(worktreePath, filePath);
if (stats.size <= MAX_FILE_SIZE) {
modified = await secureFs.readFile(worktreePath, filePath);
} else {
modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`;
}
} catch {
// File doesn't exist or validation failed - that's ok for diff display
modified = "";
}

Expand Down
Loading
Loading