Skip to content
71 changes: 65 additions & 6 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,41 @@ const c = {

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 = execFileSync(
'git',
['-C', projectPath, 'rev-parse', '--show-toplevel'],
{ 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
if (e?.code === 'ENOENT') {
console.warn('[WARN] git not found; falling back to projectPath for session tracking');
}
}
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, execFileSync } from 'child_process';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
Expand Down Expand Up @@ -935,6 +963,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') {
Expand Down Expand Up @@ -1062,7 +1097,19 @@ 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
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;
Expand All @@ -1081,7 +1128,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) {
Expand Down Expand Up @@ -1114,6 +1164,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;
Expand Down Expand Up @@ -1172,16 +1228,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)`;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Shell command injection risk via resumePath in PTY commands.

resumePath (derived from git output) and projectPath (from WebSocket input) are interpolated into shell command strings with only double-quote wrapping. A projectPath containing ", `, $(), or \ can escape the quotes and inject commands. While resumePath from git rev-parse output is generally safe, the projectPath fallback paths at lines 1236 and 1243 pass user-controlled input through a shell.

This is a pre-existing pattern in the file, but the new fallback logic at lines 1236 and 1243 duplicates and extends it. Consider using execFileAsync/argument arrays for the git-root detection part, or at minimum sanitizing projectPath before interpolation.

🔒 Minimal mitigation for projectPath
+// Sanitize path for shell interpolation - reject paths with shell metacharacters
+function sanitizePathForShell(p) {
+    if (/[`$"\\!]/.test(p)) {
+        throw new Error('Invalid characters in project path');
+    }
+    return p;
+}
+
 // In the init handler, before building shellCommand:
+const safeProjectPath = sanitizePathForShell(projectPath);
+const safeResumePath = sanitizePathForShell(resumePath);

Then use safeProjectPath / safeResumePath in shell command strings.

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

In `@server/index.js` around lines 1231 - 1243, The shell command construction
using resumePath and projectPath when building shellCommand (in the
hasSession/sessionId branches for both Windows and non-Windows) is vulnerable to
shell injection; update the implementation to avoid interpolating raw paths into
a single shell string: either (preferred) call the claude command with argument
arrays (e.g., use child_process.spawn or execFile with args) so you pass
resumePath/projectPath as safe arguments, or (if keeping shell strings) tightly
sanitize/escape resumePath and projectPath (e.g., reject/control characters like
", `, $, \\ and newlines and wrap safely) and replace the interpolated usage of
resumePath/projectPath in shellCommand with the safe variables
(safeResumePath/safeProjectPath) used in both the Windows (Set-Location ...;
claude ...) and POSIX (cd ... && claude || (cd ... && claude)) branches to
eliminate injection risk.

} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
Expand Down
Loading