From feb6ed71391f246a7a0b67ce645ccaada7a1f484 Mon Sep 17 00:00:00 2001 From: "lukas.kraic" Date: Sat, 24 Jan 2026 22:05:23 +0100 Subject: [PATCH 1/9] fix: use git root for session tracking to prevent session loss When Claude changes directories during work (e.g., cd into subdirectory), sessions were being lost because: 1. Chat sessions used the current working directory (cwd) as identifier 2. Terminal sessions used projectPath for session key 3. Source Control operations failed in subdirectories This fix detects the git repository root and uses it consistently: - Chat tab: Uses git root for cwd option in Claude SDK calls - Terminal tab: Uses git root for PTY session key and resume commands - Source Control tab: validateGitRepository now returns git root instead of throwing error, allowing all git operations from subdirectories Fixes session loss when working in monorepos or when Claude navigates to different directories within a project. --- server/index.js | 44 +++++++++++++-- server/routes/git.js | 132 ++++++++++++++++++++++--------------------- 2 files changed, 106 insertions(+), 70 deletions(-) diff --git a/server/index.js b/server/index.js index 1c42d305a..b646a1692 100755 --- a/server/index.js +++ b/server/index.js @@ -46,13 +46,34 @@ try { console.log('PORT from env:', process.env.PORT); +/** + * Helper function to get git root for session consistency. + * When Claude changes directories during work, this ensures sessions + * are tracked by the git repository root, not the current working directory. + * @param {string} projectPath - The current project/working directory + * @returns {string} The git root directory, or projectPath if not in a git repo + */ +function getGitRoot(projectPath) { + if (!projectPath) return projectPath; + try { + const gitRoot = execSync(`git -C "${projectPath}" rev-parse --show-toplevel 2>/dev/null`, { encoding: 'utf8' }).trim(); + if (gitRoot) { + console.log('🔧 Git root detected:', gitRoot, 'from:', projectPath); + return gitRoot; + } + } catch (e) { + // Not a git repository, use the original path + } + return projectPath; +} + import express from 'express'; import { WebSocketServer, WebSocket } from 'ws'; import os from 'os'; import http from 'http'; import cors from 'cors'; import { promises as fsPromises } from 'fs'; -import { spawn } from 'child_process'; +import { spawn, execSync } from 'child_process'; import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; @@ -857,6 +878,13 @@ function handleChatConnection(ws) { console.log('📁 Project:', data.options?.projectPath || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); + // Use git root for cwd to prevent session loss when Claude changes directories + const chatProjectPath = data.options?.cwd || data.options?.projectPath || process.cwd(); + const chatGitRoot = getGitRoot(chatProjectPath); + if (data.options) { + data.options.cwd = chatGitRoot; + } + // Use Claude Agents SDK await queryClaudeSDK(data.command, data.options, writer); } else if (data.type === 'cursor-command') { @@ -1000,7 +1028,10 @@ function handleShellConnection(ws) { const commandSuffix = isPlainShell && initialCommand ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}` : ''; - ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`; + + // Use git root for session key to prevent session loss when Claude changes directories + const shellGitRoot = getGitRoot(projectPath); + ptySessionKey = `${shellGitRoot}_${sessionId || 'default'}${commandSuffix}`; // Kill any existing login session before starting fresh if (isLoginCommand) { @@ -1091,16 +1122,19 @@ function handleShellConnection(ws) { } else { // Use claude command (default) or initialCommand if provided const command = initialCommand || 'claude'; + // Use git root for resume to ensure session is found even if cwd changed + const resumePath = shellGitRoot || projectPath; if (os.platform() === 'win32') { if (hasSession && sessionId) { - // Try to resume session, but with fallback to new session if it fails - shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`; + // Try to resume session from git root, fallback to new session in projectPath + shellCommand = `Set-Location -Path "${resumePath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { Set-Location -Path "${projectPath}"; claude }`; } else { shellCommand = `Set-Location -Path "${projectPath}"; ${command}`; } } else { if (hasSession && sessionId) { - shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`; + // Resume from git root, fallback to new session in projectPath if resume fails + shellCommand = `cd "${resumePath}" && claude --resume ${sessionId} || (cd "${projectPath}" && claude)`; } else { shellCommand = `cd "${projectPath}" && ${command}`; } diff --git a/server/routes/git.js b/server/routes/git.js index 0df4e44dd..494a3aeb6 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -50,7 +50,14 @@ function stripDiffHeaders(diff) { return filteredLines.join('\n'); } -// Helper function to validate git repository +/** + * Helper function to validate git repository and return the git root. + * This allows git operations to work from any subdirectory within a repository, + * which is important when Claude changes the working directory during a session. + * @param {string} projectPath - The current project/working directory + * @returns {Promise} The git root directory + * @throws {Error} If projectPath doesn't exist or is not in a git repository + */ async function validateGitRepository(projectPath) { try { // Check if directory exists @@ -61,18 +68,13 @@ async function validateGitRepository(projectPath) { try { // Use --show-toplevel to get the root of the git repository - const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); + const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: gitRoot }); const normalizedGitRoot = path.resolve(gitRoot.trim()); - const normalizedProjectPath = path.resolve(projectPath); - - // Ensure the git root matches our project path (prevent using parent git repos) - if (normalizedGitRoot !== normalizedProjectPath) { - throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`); - } + + // Return the git root - this allows subdirectories to work correctly + console.log('🔧 Git root for source control:', normalizedGitRoot, 'from:', projectPath); + return normalizedGitRoot; } catch (error) { - if (error.message.includes('Project directory is not a git repository')) { - throw error; - } throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } } @@ -89,13 +91,13 @@ router.get('/status', async (req, res) => { const projectPath = await getActualProjectPath(project); // Validate git repository - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get current branch - handle case where there are no commits yet let branch = 'main'; let hasCommits = true; try { - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); branch = branchOutput.trim(); } catch (error) { // No HEAD exists - repository has no commits yet @@ -108,7 +110,7 @@ router.get('/status', async (req, res) => { } // Get git status - const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath }); + const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: gitRoot }); const modified = []; const added = []; @@ -165,10 +167,10 @@ router.get('/diff', async (req, res) => { const projectPath = await getActualProjectPath(project); // Validate git repository - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Check if file is untracked or deleted - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -189,21 +191,21 @@ router.get('/diff', async (req, res) => { } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions - const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: gitRoot }); const lines = fileContent.split('\n'); diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) - const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath }); + const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: gitRoot }); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) - const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); + const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: gitRoot }); diff = stripDiffHeaders(stagedDiff) || ''; } } @@ -227,10 +229,10 @@ router.get('/file-with-diff', async (req, res) => { const projectPath = await getActualProjectPath(project); // Validate git repository - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Check file status - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -239,7 +241,7 @@ router.get('/file-with-diff', async (req, res) => { if (isDeleted) { // For deleted files, get content from HEAD - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: gitRoot }); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { @@ -257,7 +259,7 @@ router.get('/file-with-diff', async (req, res) => { if (!isUntracked) { // Get the old content from HEAD for tracked files try { - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: gitRoot }); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) @@ -290,21 +292,21 @@ router.post('/initial-commit', async (req, res) => { const projectPath = await getActualProjectPath(project); // Validate git repository - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Check if there are already commits try { - await execAsync('git rev-parse HEAD', { cwd: projectPath }); + await execAsync('git rev-parse HEAD', { cwd: gitRoot }); return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' }); } catch (error) { // No HEAD - this is good, we can create initial commit } // Add all files - await execAsync('git add .', { cwd: projectPath }); + await execAsync('git add .', { cwd: gitRoot }); // Create initial commit - const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath }); + const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: gitRoot }); res.json({ success: true, output: stdout, message: 'Initial commit created successfully' }); } catch (error) { @@ -334,15 +336,15 @@ router.post('/commit', async (req, res) => { const projectPath = await getActualProjectPath(project); // Validate git repository - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Stage selected files for (const file of files) { - await execAsync(`git add "${file}"`, { cwd: projectPath }); + await execAsync(`git add "${file}"`, { cwd: gitRoot }); } // Commit with message - const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath }); + const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: gitRoot }); res.json({ success: true, output: stdout }); } catch (error) { @@ -363,10 +365,10 @@ router.get('/branches', async (req, res) => { const projectPath = await getActualProjectPath(project); // Validate git repository - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get all branches - const { stdout } = await execAsync('git branch -a', { cwd: projectPath }); + const { stdout } = await execAsync('git branch -a', { cwd: gitRoot }); // Parse branches const branches = stdout @@ -405,7 +407,7 @@ router.post('/checkout', async (req, res) => { const projectPath = await getActualProjectPath(project); // Checkout the branch - const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath }); + const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: gitRoot }); res.json({ success: true, output: stdout }); } catch (error) { @@ -426,7 +428,7 @@ router.post('/create-branch', async (req, res) => { const projectPath = await getActualProjectPath(project); // Create and checkout new branch - const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath }); + const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: gitRoot }); res.json({ success: true, output: stdout }); } catch (error) { @@ -449,7 +451,7 @@ router.get('/commits', async (req, res) => { // Get commit log with stats const { stdout } = await execAsync( `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`, - { cwd: projectPath } + { cwd: gitRoot } ); const commits = stdout @@ -471,7 +473,7 @@ router.get('/commits', async (req, res) => { try { const { stdout: stats } = await execAsync( `git show --stat --format='' ${commit.hash}`, - { cwd: projectPath } + { cwd: gitRoot } ); commit.stats = stats.trim().split('\n').pop(); // Get the summary line } catch (error) { @@ -500,7 +502,7 @@ router.get('/commit-diff', async (req, res) => { // Get diff for the commit const { stdout } = await execAsync( `git show ${commit}`, - { cwd: projectPath } + { cwd: gitRoot } ); res.json({ diff: stdout }); @@ -532,7 +534,7 @@ router.post('/generate-commit-message', async (req, res) => { try { const { stdout } = await execAsync( `git diff HEAD -- "${file}"`, - { cwd: projectPath } + { cwd: gitRoot } ); if (stdout) { diffContext += `\n--- ${file} ---\n${stdout}`; @@ -721,17 +723,17 @@ router.get('/remote-status', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); const branch = currentBranch.trim(); // Check if there's a remote tracking branch (smart detection) let trackingBranch; let remoteName; try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { @@ -739,7 +741,7 @@ router.get('/remote-status', async (req, res) => { let hasRemote = false; let remoteName = null; try { - const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const { stdout } = await execAsync('git remote', { cwd: gitRoot }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length > 0) { hasRemote = true; @@ -761,7 +763,7 @@ router.get('/remote-status', async (req, res) => { // Get ahead/behind counts const { stdout: countOutput } = await execAsync( `git rev-list --count --left-right ${trackingBranch}...HEAD`, - { cwd: projectPath } + { cwd: gitRoot } ); const [behind, ahead] = countOutput.trim().split('\t').map(Number); @@ -792,22 +794,22 @@ router.post('/fetch', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); remoteName = stdout.trim().split('/')[0]; // Extract remote name } catch (error) { // No upstream, try to fetch from origin anyway console.log('No upstream configured, using origin as fallback'); } - const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: gitRoot }); res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); } catch (error) { @@ -833,16 +835,16 @@ router.post('/pull', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -851,7 +853,7 @@ router.post('/pull', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: gitRoot }); res.json({ success: true, @@ -900,16 +902,16 @@ router.post('/push', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -918,7 +920,7 @@ router.post('/push', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: gitRoot }); res.json({ success: true, @@ -970,10 +972,10 @@ router.post('/publish', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Get current branch to verify it matches the requested branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); const currentBranchName = currentBranch.trim(); if (currentBranchName !== branch) { @@ -985,7 +987,7 @@ router.post('/publish', async (req, res) => { // Check if remote exists let remoteName = 'origin'; try { - const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const { stdout } = await execAsync('git remote', { cwd: gitRoot }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length === 0) { return res.status(400).json({ @@ -1000,7 +1002,7 @@ router.post('/publish', async (req, res) => { } // Publish the branch (set upstream and push) - const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath }); + const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: gitRoot }); res.json({ success: true, @@ -1046,10 +1048,10 @@ router.post('/discard', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Check file status to determine correct discard command - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); @@ -1069,10 +1071,10 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await execAsync(`git restore "${file}"`, { cwd: projectPath }); + await execAsync(`git restore "${file}"`, { cwd: gitRoot }); } else if (status.includes('A')) { // Added file - unstage it - await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath }); + await execAsync(`git reset HEAD "${file}"`, { cwd: gitRoot }); } res.json({ success: true, message: `Changes discarded for ${file}` }); @@ -1092,10 +1094,10 @@ router.post('/delete-untracked', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - await validateGitRepository(projectPath); + const gitRoot = await validateGitRepository(projectPath); // Check if file is actually untracked - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); From 36d5a491e43dd7b345d9f316a03301414276e591 Mon Sep 17 00:00:00 2001 From: "lukas.kraic" Date: Sat, 24 Jan 2026 23:29:22 +0100 Subject: [PATCH 2/9] Fix undefined gitRoot variable in validateGitRepository and routes - Fix validateGitRepository to use projectPath instead of undefined gitRoot - Add missing gitRoot definition to checkout, create-branch, commits, commit-diff, and generate-commit-message routes - Rename stdout destructuring to avoid variable shadowing --- server/routes/git.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index 494a3aeb6..023462096 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -68,8 +68,8 @@ async function validateGitRepository(projectPath) { try { // Use --show-toplevel to get the root of the git repository - const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: gitRoot }); - const normalizedGitRoot = path.resolve(gitRoot.trim()); + const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); + const normalizedGitRoot = path.resolve(stdout.trim()); // Return the git root - this allows subdirectories to work correctly console.log('🔧 Git root for source control:', normalizedGitRoot, 'from:', projectPath); @@ -405,7 +405,8 @@ router.post('/checkout', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - + const gitRoot = await validateGitRepository(projectPath); + // Checkout the branch const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: gitRoot }); @@ -426,7 +427,8 @@ router.post('/create-branch', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - + const gitRoot = await validateGitRepository(projectPath); + // Create and checkout new branch const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: gitRoot }); @@ -447,7 +449,8 @@ router.get('/commits', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - + const gitRoot = await validateGitRepository(projectPath); + // Get commit log with stats const { stdout } = await execAsync( `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`, @@ -498,7 +501,8 @@ router.get('/commit-diff', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - + const gitRoot = await validateGitRepository(projectPath); + // Get diff for the commit const { stdout } = await execAsync( `git show ${commit}`, @@ -527,6 +531,7 @@ router.post('/generate-commit-message', async (req, res) => { try { const projectPath = await getActualProjectPath(project); + const gitRoot = await validateGitRepository(projectPath); // Get diff for selected files let diffContext = ''; From debd381f7eb5b122636aa66b4a817f700ed6934c Mon Sep 17 00:00:00 2001 From: "lukas.kraic" Date: Sun, 25 Jan 2026 15:02:38 +0100 Subject: [PATCH 3/9] Fix shell injection vulnerability in getGitRoot Use execFileSync with argument array instead of execSync with string interpolation to prevent potential command injection via projectPath. --- server/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/index.js b/server/index.js index b646a1692..1ccef2fcd 100755 --- a/server/index.js +++ b/server/index.js @@ -56,7 +56,11 @@ console.log('PORT from env:', process.env.PORT); function getGitRoot(projectPath) { if (!projectPath) return projectPath; try { - const gitRoot = execSync(`git -C "${projectPath}" rev-parse --show-toplevel 2>/dev/null`, { encoding: 'utf8' }).trim(); + const gitRoot = execFileSync( + 'git', + ['-C', projectPath, 'rev-parse', '--show-toplevel'], + { encoding: 'utf8' } + ).trim(); if (gitRoot) { console.log('🔧 Git root detected:', gitRoot, 'from:', projectPath); return gitRoot; @@ -73,7 +77,7 @@ import os from 'os'; import http from 'http'; import cors from 'cors'; import { promises as fsPromises } from 'fs'; -import { spawn, execSync } from 'child_process'; +import { spawn, execFileSync } from 'child_process'; import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; From 5d0b27d46aecf9ec873f22a81f32d1c4df6cdb27 Mon Sep 17 00:00:00 2001 From: "lukas.kraic" Date: Sun, 25 Jan 2026 15:16:29 +0100 Subject: [PATCH 4/9] Sanitize sessionId and scroll to end on reconnect - Add sessionId sanitization to prevent command injection - Scroll terminal to bottom after buffer replay on reconnect --- server/index.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/index.js b/server/index.js index 1ccef2fcd..4d95cac42 100755 --- a/server/index.js +++ b/server/index.js @@ -1015,7 +1015,9 @@ function handleShellConnection(ws) { if (data.type === 'init') { const projectPath = data.projectPath || process.cwd(); - const sessionId = data.sessionId; + const rawSessionId = data.sessionId; + // Sanitize sessionId to prevent command injection + const sessionId = rawSessionId ? String(rawSessionId).replace(/[^a-zA-Z0-9._-]/g, '') : null; const hasSession = data.hasSession; const provider = data.provider || 'claude'; const initialCommand = data.initialCommand; @@ -1068,6 +1070,12 @@ function handleShellConnection(ws) { data: bufferedData })); }); + // Send ANSI escape sequence to scroll terminal to bottom + // CSI 999999 H moves cursor to row 999999 which scrolls to end + ws.send(JSON.stringify({ + type: 'output', + data: '\x1b[999999;1H' + })); } existingSession.ws = ws; From 886e488b864e73946114ceb4feeb8d065bfb0185 Mon Sep 17 00:00:00 2001 From: "lukas.kraic" Date: Sun, 25 Jan 2026 15:23:05 +0100 Subject: [PATCH 5/9] Reject invalid sessionId and add git ENOENT warning - Reject and close connection if sessionId contains only invalid chars - Add warning log when git binary is not found (ENOENT) --- server/index.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/server/index.js b/server/index.js index 4d95cac42..5fea133e0 100755 --- a/server/index.js +++ b/server/index.js @@ -67,6 +67,9 @@ function getGitRoot(projectPath) { } } catch (e) { // Not a git repository, use the original path + if (e?.code === 'ENOENT') { + console.warn('[WARN] git not found; falling back to projectPath for session tracking'); + } } return projectPath; } @@ -1017,7 +1020,17 @@ function handleShellConnection(ws) { const projectPath = data.projectPath || process.cwd(); const rawSessionId = data.sessionId; // Sanitize sessionId to prevent command injection - const sessionId = rawSessionId ? String(rawSessionId).replace(/[^a-zA-Z0-9._-]/g, '') : null; + let sessionId = rawSessionId ? String(rawSessionId).replace(/[^a-zA-Z0-9._-]/g, '') : null; + // Reject if sessionId was provided but sanitization removed all characters + if (rawSessionId && !sessionId) { + console.warn('[WARN] Invalid sessionId rejected:', rawSessionId); + ws.send(JSON.stringify({ + type: 'output', + data: '\x1b[31m[Error] Invalid session ID format\x1b[0m\r\n' + })); + ws.close(); + return; + } const hasSession = data.hasSession; const provider = data.provider || 'claude'; const initialCommand = data.initialCommand; From 5b6cf43a54120bb235c2f827b9b2e85888ec721e Mon Sep 17 00:00:00 2001 From: Lukas Kraic Date: Fri, 30 Jan 2026 15:16:11 +0100 Subject: [PATCH 6/9] fix: use gitRoot for file path operations in git routes File paths returned by git commands (status --porcelain, etc.) are relative to the git repository root, not the current projectPath. When Claude changes directories during work, projectPath may be a subdirectory, causing file operations to fail. This fixes file read/write/delete operations in: - /diff endpoint (untracked file content) - /file-with-diff endpoint (current file content) - /generate-commit-message endpoint (untracked file content) - /discard endpoint (delete untracked files) - /delete-untracked endpoint (delete untracked files) --- server/routes/git.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index 023462096..ce801269c 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -177,7 +177,8 @@ router.get('/diff', async (req, res) => { let diff; if (isUntracked) { // For untracked files, show the entire file content as additions - const filePath = path.join(projectPath, file); + // Use gitRoot because file paths from git commands are relative to git root + const filePath = path.join(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -246,7 +247,8 @@ router.get('/file-with-diff', async (req, res) => { currentContent = headContent; // Show the deleted content in editor } else { // Get current file content - const filePath = path.join(projectPath, file); + // Use gitRoot because file paths from git commands are relative to git root + const filePath = path.join(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -552,9 +554,10 @@ router.post('/generate-commit-message', async (req, res) => { // If no diff found, might be untracked files if (!diffContext.trim()) { // Try to get content of untracked files + // Use gitRoot because file paths are relative to git root for (const file of files) { try { - const filePath = path.join(projectPath, file); + const filePath = path.join(gitRoot, file); const stats = await fs.stat(filePath); if (!stats.isDirectory()) { @@ -1066,7 +1069,8 @@ router.post('/discard', async (req, res) => { if (status === '??') { // Untracked file or directory - delete it - const filePath = path.join(projectPath, file); + // Use gitRoot because file paths from git commands are relative to git root + const filePath = path.join(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -1115,7 +1119,8 @@ router.post('/delete-untracked', async (req, res) => { } // Delete the untracked file or directory - const filePath = path.join(projectPath, file); + // Use gitRoot because file paths from git commands are relative to git root + const filePath = path.join(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { From 417d18d2c953154a2030586801cb31044ada6a67 Mon Sep 17 00:00:00 2001 From: Lukas Kraic Date: Fri, 30 Jan 2026 15:38:24 +0100 Subject: [PATCH 7/9] fix(security): prevent command injection and path traversal - Add validateFilePath() helper to prevent path traversal attacks by ensuring file paths stay within the git repository root - Fix /commits endpoint: validate and sanitize limit parameter, use execFileAsync instead of string interpolation to prevent command injection (e.g., limit=10;rm -rf /) - Apply path validation to all endpoints that read/write files: /diff, /file-with-diff, /generate-commit-message, /discard, /delete-untracked Security issues identified by CodeRabbit review. --- server/routes/git.js | 51 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index ce801269c..6d4dcc2ea 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -1,5 +1,5 @@ import express from 'express'; -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { promises as fs } from 'fs'; @@ -9,6 +9,27 @@ import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +/** + * Validates that a file path is within the allowed root directory. + * Prevents path traversal attacks (e.g., ../../etc/passwd). + * @param {string} rootDir - The root directory that paths must be within + * @param {string} filePath - The file path to validate (can be relative) + * @returns {string} The resolved absolute path if valid + * @throws {Error} If the path escapes the root directory + */ +function validateFilePath(rootDir, filePath) { + const resolvedRoot = path.resolve(rootDir); + const resolvedPath = path.resolve(rootDir, filePath); + + // Ensure the resolved path starts with the root directory + if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) { + throw new Error(`Invalid file path: path escapes repository root`); + } + + return resolvedPath; +} // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { @@ -178,7 +199,8 @@ router.get('/diff', async (req, res) => { if (isUntracked) { // For untracked files, show the entire file content as additions // Use gitRoot because file paths from git commands are relative to git root - const filePath = path.join(gitRoot, file); + // Validate path to prevent traversal attacks + const filePath = validateFilePath(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -248,7 +270,8 @@ router.get('/file-with-diff', async (req, res) => { } else { // Get current file content // Use gitRoot because file paths from git commands are relative to git root - const filePath = path.join(gitRoot, file); + // Validate path to prevent traversal attacks + const filePath = validateFilePath(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -443,8 +466,10 @@ router.post('/create-branch', async (req, res) => { // Get recent commits router.get('/commits', async (req, res) => { - const { project, limit = 10 } = req.query; - + const { project, limit: limitParam = '10' } = req.query; + // Validate and sanitize limit parameter to prevent command injection + const limit = Math.min(Math.max(1, parseInt(limitParam, 10) || 10), 100); + if (!project) { return res.status(400).json({ error: 'Project name is required' }); } @@ -453,9 +478,10 @@ router.get('/commits', async (req, res) => { const projectPath = await getActualProjectPath(project); const gitRoot = await validateGitRepository(projectPath); - // Get commit log with stats - const { stdout } = await execAsync( - `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`, + // Get commit log with stats - use execFileAsync to prevent command injection + const { stdout } = await execFileAsync( + 'git', + ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(limit)], { cwd: gitRoot } ); @@ -557,7 +583,8 @@ router.post('/generate-commit-message', async (req, res) => { // Use gitRoot because file paths are relative to git root for (const file of files) { try { - const filePath = path.join(gitRoot, file); + // Validate path to prevent traversal attacks + const filePath = validateFilePath(gitRoot, file); const stats = await fs.stat(filePath); if (!stats.isDirectory()) { @@ -1070,7 +1097,8 @@ router.post('/discard', async (req, res) => { if (status === '??') { // Untracked file or directory - delete it // Use gitRoot because file paths from git commands are relative to git root - const filePath = path.join(gitRoot, file); + // Validate path to prevent traversal attacks + const filePath = validateFilePath(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -1120,7 +1148,8 @@ router.post('/delete-untracked', async (req, res) => { // Delete the untracked file or directory // Use gitRoot because file paths from git commands are relative to git root - const filePath = path.join(gitRoot, file); + // Validate path to prevent traversal attacks + const filePath = validateFilePath(gitRoot, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { From 5242da556d1060e4d6d1e914a47b724d54d5fc0b Mon Sep 17 00:00:00 2001 From: Lukas Kraic Date: Fri, 30 Jan 2026 15:46:33 +0100 Subject: [PATCH 8/9] fix(security): convert all execAsync to execFileAsync for command injection prevention Convert all git command executions from shell-interpolated execAsync to argument-based execFileAsync to prevent command injection attacks. Affected endpoints: - /diff: git status, git show, git diff - /file-with-diff: git status, git show - /commit: git add, git commit - /checkout: git checkout - /create-branch: git checkout -b - /commits: git show --stat - /commit-diff: git show - /generate-commit-message: git diff - /remote-status: git rev-parse, git remote, git rev-list - /fetch: git rev-parse, git fetch - /pull: git rev-parse, git pull - /push: git rev-parse, git push - /publish: git rev-parse, git remote, git push - /discard: git status, git restore, git reset - /delete-untracked: git status Using execFileAsync with argument arrays prevents shell interpretation of user-controlled values like file paths, branch names, and commit messages. --- server/routes/git.js | 142 ++++++++++++++++++++++--------------------- 1 file changed, 73 insertions(+), 69 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index 6d4dcc2ea..23a19e673 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -191,7 +191,7 @@ router.get('/diff', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check if file is untracked or deleted - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -214,21 +214,21 @@ router.get('/diff', async (req, res) => { } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions - const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: gitRoot }); + const { stdout: fileContent } = await execFileAsync('git', ['show', `HEAD:${file}`], { cwd: gitRoot }); const lines = fileContent.split('\n'); diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) - const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: gitRoot }); + const { stdout: unstagedDiff } = await execFileAsync('git', ['diff', '--', file], { cwd: gitRoot }); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) - const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: gitRoot }); + const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached', '--', file], { cwd: gitRoot }); diff = stripDiffHeaders(stagedDiff) || ''; } } @@ -255,7 +255,7 @@ router.get('/file-with-diff', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check file status - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -264,7 +264,7 @@ router.get('/file-with-diff', async (req, res) => { if (isDeleted) { // For deleted files, get content from HEAD - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: gitRoot }); + const { stdout: headContent } = await execFileAsync('git', ['show', `HEAD:${file}`], { cwd: gitRoot }); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { @@ -284,7 +284,7 @@ router.get('/file-with-diff', async (req, res) => { if (!isUntracked) { // Get the old content from HEAD for tracked files try { - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: gitRoot }); + const { stdout: headContent } = await execFileAsync('git', ['show', `HEAD:${file}`], { cwd: gitRoot }); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) @@ -365,11 +365,11 @@ router.post('/commit', async (req, res) => { // Stage selected files for (const file of files) { - await execAsync(`git add "${file}"`, { cwd: gitRoot }); + await execFileAsync('git', ['add', file], { cwd: gitRoot }); } - + // Commit with message - const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['commit', '-m', message], { cwd: gitRoot }); res.json({ success: true, output: stdout }); } catch (error) { @@ -433,8 +433,8 @@ router.post('/checkout', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Checkout the branch - const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: gitRoot }); - + const { stdout } = await execFileAsync('git', ['checkout', branch], { cwd: gitRoot }); + res.json({ success: true, output: stdout }); } catch (error) { console.error('Git checkout error:', error); @@ -445,7 +445,7 @@ router.post('/checkout', async (req, res) => { // Create new branch router.post('/create-branch', async (req, res) => { const { project, branch } = req.body; - + if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch name are required' }); } @@ -455,7 +455,7 @@ router.post('/create-branch', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Create and checkout new branch - const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['checkout', '-b', branch], { cwd: gitRoot }); res.json({ success: true, output: stdout }); } catch (error) { @@ -502,8 +502,9 @@ router.get('/commits', async (req, res) => { // Get stats for each commit for (const commit of commits) { try { - const { stdout: stats } = await execAsync( - `git show --stat --format='' ${commit.hash}`, + const { stdout: stats } = await execFileAsync( + 'git', + ['show', '--stat', '--format=', commit.hash], { cwd: gitRoot } ); commit.stats = stats.trim().split('\n').pop(); // Get the summary line @@ -511,7 +512,7 @@ router.get('/commits', async (req, res) => { commit.stats = ''; } } - + res.json({ commits }); } catch (error) { console.error('Git commits error:', error); @@ -522,7 +523,7 @@ router.get('/commits', async (req, res) => { // Get diff for a specific commit router.get('/commit-diff', async (req, res) => { const { project, commit } = req.query; - + if (!project || !commit) { return res.status(400).json({ error: 'Project name and commit hash are required' }); } @@ -532,8 +533,9 @@ router.get('/commit-diff', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Get diff for the commit - const { stdout } = await execAsync( - `git show ${commit}`, + const { stdout } = await execFileAsync( + 'git', + ['show', commit], { cwd: gitRoot } ); @@ -565,8 +567,9 @@ router.post('/generate-commit-message', async (req, res) => { let diffContext = ''; for (const file of files) { try { - const { stdout } = await execAsync( - `git diff HEAD -- "${file}"`, + const { stdout } = await execFileAsync( + 'git', + ['diff', 'HEAD', '--', file], { cwd: gitRoot } ); if (stdout) { @@ -761,14 +764,14 @@ router.get('/remote-status', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Get current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); + const { stdout: currentBranch } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: gitRoot }); const branch = currentBranch.trim(); // Check if there's a remote tracking branch (smart detection) let trackingBranch; let remoteName; try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: gitRoot }); trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { @@ -776,7 +779,7 @@ router.get('/remote-status', async (req, res) => { let hasRemote = false; let remoteName = null; try { - const { stdout } = await execAsync('git remote', { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['remote'], { cwd: gitRoot }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length > 0) { hasRemote = true; @@ -785,8 +788,8 @@ router.get('/remote-status', async (req, res) => { } catch (remoteError) { // No remotes configured } - - return res.json({ + + return res.json({ hasRemote, hasUpstream: false, branch, @@ -796,8 +799,9 @@ router.get('/remote-status', async (req, res) => { } // Get ahead/behind counts - const { stdout: countOutput } = await execAsync( - `git rev-list --count --left-right ${trackingBranch}...HEAD`, + const { stdout: countOutput } = await execFileAsync( + 'git', + ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`], { cwd: gitRoot } ); @@ -832,20 +836,20 @@ router.post('/fetch', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); + const { stdout: currentBranch } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: gitRoot }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: gitRoot }); remoteName = stdout.trim().split('/')[0]; // Extract remote name } catch (error) { // No upstream, try to fetch from origin anyway console.log('No upstream configured, using origin as fallback'); } - const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: gitRoot }); - + const { stdout } = await execFileAsync('git', ['fetch', remoteName], { cwd: gitRoot }); + res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); } catch (error) { console.error('Git fetch error:', error); @@ -873,13 +877,13 @@ router.post('/pull', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); + const { stdout: currentBranch } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: gitRoot }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: gitRoot }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -888,11 +892,11 @@ router.post('/pull', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: gitRoot }); - - res.json({ - success: true, - output: stdout || 'Pull completed successfully', + const { stdout } = await execFileAsync('git', ['pull', remoteName, remoteBranch], { cwd: gitRoot }); + + res.json({ + success: true, + output: stdout || 'Pull completed successfully', remoteName, remoteBranch }); @@ -940,13 +944,13 @@ router.post('/push', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); + const { stdout: currentBranch } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: gitRoot }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: gitRoot }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -955,17 +959,17 @@ router.post('/push', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: gitRoot }); - - res.json({ - success: true, - output: stdout || 'Push completed successfully', + const { stdout } = await execFileAsync('git', ['push', remoteName, remoteBranch], { cwd: gitRoot }); + + res.json({ + success: true, + output: stdout || 'Push completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git push error:', error); - + // Enhanced error handling for common push scenarios let errorMessage = 'Push failed'; let details = error.message; @@ -1010,38 +1014,38 @@ router.post('/publish', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Get current branch to verify it matches the requested branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: gitRoot }); + const { stdout: currentBranch } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: gitRoot }); const currentBranchName = currentBranch.trim(); - + if (currentBranchName !== branch) { - return res.status(400).json({ - error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` + return res.status(400).json({ + error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` }); } // Check if remote exists let remoteName = 'origin'; try { - const { stdout } = await execAsync('git remote', { cwd: gitRoot }); + const { stdout } = await execFileAsync('git', ['remote'], { cwd: gitRoot }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length === 0) { - return res.status(400).json({ - error: 'No remote repository configured. Add a remote with: git remote add origin ' + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } catch (error) { - return res.status(400).json({ - error: 'No remote repository configured. Add a remote with: git remote add origin ' + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } // Publish the branch (set upstream and push) - const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: gitRoot }); - - res.json({ - success: true, - output: stdout || 'Branch published successfully', + const { stdout } = await execFileAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: gitRoot }); + + res.json({ + success: true, + output: stdout || 'Branch published successfully', remoteName, branch }); @@ -1086,8 +1090,8 @@ router.post('/discard', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check file status to determine correct discard command - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); - + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); + if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); } @@ -1108,12 +1112,12 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await execAsync(`git restore "${file}"`, { cwd: gitRoot }); + await execFileAsync('git', ['restore', file], { cwd: gitRoot }); } else if (status.includes('A')) { // Added file - unstage it - await execAsync(`git reset HEAD "${file}"`, { cwd: gitRoot }); + await execFileAsync('git', ['reset', 'HEAD', file], { cwd: gitRoot }); } - + res.json({ success: true, message: `Changes discarded for ${file}` }); } catch (error) { console.error('Git discard error:', error); @@ -1134,8 +1138,8 @@ router.post('/delete-untracked', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check if file is actually untracked - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: gitRoot }); - + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); + if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); } From c7b80c2fbe93d000188534e40c86fc215e0a3775 Mon Sep 17 00:00:00 2001 From: Lukas Kraic Date: Fri, 30 Jan 2026 15:53:23 +0100 Subject: [PATCH 9/9] fix(security): add pathspec separator and improve path validation - Add '--' before file pathspecs in git commands to prevent option injection (e.g., filename starting with '-' being interpreted as option) Affected commands: status, add, restore, reset - Improve validateFilePath to use path.relative() for robust containment check that correctly handles edge cases like filesystem root as repo root --- server/routes/git.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index 23a19e673..d6dcb9473 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -23,8 +23,12 @@ function validateFilePath(rootDir, filePath) { const resolvedRoot = path.resolve(rootDir); const resolvedPath = path.resolve(rootDir, filePath); - // Ensure the resolved path starts with the root directory - if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) { + // Use path.relative for robust containment check that handles edge cases + // like filesystem root as repository root + const relativePath = path.relative(resolvedRoot, resolvedPath); + + // Path escapes root if relative path starts with '..' or is absolute + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { throw new Error(`Invalid file path: path escapes repository root`); } @@ -191,7 +195,7 @@ router.get('/diff', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check if file is untracked or deleted - const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd: gitRoot }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -255,7 +259,7 @@ router.get('/file-with-diff', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check file status - const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd: gitRoot }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -365,7 +369,7 @@ router.post('/commit', async (req, res) => { // Stage selected files for (const file of files) { - await execFileAsync('git', ['add', file], { cwd: gitRoot }); + await execFileAsync('git', ['add', '--', file], { cwd: gitRoot }); } // Commit with message @@ -1090,7 +1094,7 @@ router.post('/discard', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check file status to determine correct discard command - const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd: gitRoot }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); @@ -1112,10 +1116,10 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await execFileAsync('git', ['restore', file], { cwd: gitRoot }); + await execFileAsync('git', ['restore', '--', file], { cwd: gitRoot }); } else if (status.includes('A')) { // Added file - unstage it - await execFileAsync('git', ['reset', 'HEAD', file], { cwd: gitRoot }); + await execFileAsync('git', ['reset', 'HEAD', '--', file], { cwd: gitRoot }); } res.json({ success: true, message: `Changes discarded for ${file}` }); @@ -1138,7 +1142,7 @@ router.post('/delete-untracked', async (req, res) => { const gitRoot = await validateGitRepository(projectPath); // Check if file is actually untracked - const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', file], { cwd: gitRoot }); + const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd: gitRoot }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' });