+216
-403
@@ -23,7 +23,7 @@ #!/usr/bin/env node | ||
| const VERSION = '1.2.0'; | ||
| const VERSION = '1.3.0'; | ||
| const PACKAGE_NAME = 'ccgate'; | ||
| // ============================================================================= | ||
| // CLI Argument Parsing | ||
| // CLI | ||
| // ============================================================================= | ||
@@ -51,3 +51,2 @@ | ||
| // Get origin from positional argument | ||
| const origin = args.find(arg => !arg.startsWith('-')); | ||
@@ -67,3 +66,2 @@ | ||
| // Validate URL format | ||
| let parsed; | ||
@@ -77,3 +75,2 @@ try { | ||
| // Require http or https | ||
| if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { | ||
@@ -84,6 +81,3 @@ console.error(`Error: Origin must use http or https protocol`); | ||
| // Normalize to proper origin format (scheme://host:port, no path/query/fragment/credentials) | ||
| const normalizedOrigin = parsed.origin; | ||
| return startServer(normalizedOrigin); | ||
| return startServer(parsed.origin); | ||
| } | ||
@@ -98,8 +92,4 @@ | ||
| port: parseInt(process.env.PORT, 10) || 3456, | ||
| host: '127.0.0.1', // Security: only listen on localhost | ||
| // Request limits | ||
| maxBodySize: 1024 * 1024, // 1MB max request body | ||
| // CORS: the single trusted origin (localhost always allowed) | ||
| host: '127.0.0.1', | ||
| maxBodySize: 1024 * 1024, // 1MB | ||
| trustedOrigin: origin, | ||
@@ -110,30 +100,15 @@ }; | ||
| // ============================================================================= | ||
| // CORS Handling | ||
| // CORS | ||
| // ============================================================================= | ||
| /** | ||
| * Validates request origin against allowlist. | ||
| * Returns the origin if trusted, null otherwise. | ||
| */ | ||
| function validateOrigin(req, config) { | ||
| const origin = req.headers.origin; | ||
| if (!origin) { | ||
| // Same-origin requests (curl, non-browser) - allow but don't set CORS | ||
| return null; | ||
| } | ||
| const reqOrigin = req.headers.origin; | ||
| if (!reqOrigin) return null; | ||
| // Check if it matches the configured origin | ||
| if (origin === config.trustedOrigin) { | ||
| return origin; | ||
| } | ||
| if (reqOrigin === config.trustedOrigin) return reqOrigin; | ||
| // Allow localhost/127.0.0.1 on any port (for local development) | ||
| try { | ||
| const url = new URL(origin); | ||
| if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { | ||
| return origin; | ||
| } | ||
| } catch { | ||
| // Invalid origin URL | ||
| } | ||
| const url = new URL(reqOrigin); | ||
| if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return reqOrigin; | ||
| } catch {} | ||
@@ -148,3 +123,3 @@ return null; | ||
| res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); | ||
| res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours | ||
| res.setHeader('Access-Control-Max-Age', '86400'); | ||
| } | ||
@@ -154,2 +129,87 @@ } | ||
| // ============================================================================= | ||
| // Shared Helpers | ||
| // ============================================================================= | ||
| /** | ||
| * Encodes a project path to the format used by Claude Code for session storage. | ||
| * All non-alphanumeric characters become '-'. | ||
| * Example: /Users/foo.bar/project -> -Users-foo-bar-project | ||
| */ | ||
| function encodeProjectPath(projectPath) { | ||
| return projectPath.replace(/[^a-zA-Z0-9]/g, '-'); | ||
| } | ||
| /** | ||
| * Extracts the working directory from a JSONL session file. | ||
| * Scans multiple lines since early entries may be file-history-snapshots without cwd. | ||
| */ | ||
| async function extractCwdFromJsonl(filePath, maxLines = 10) { | ||
| try { | ||
| const content = await readFile(filePath, 'utf-8'); | ||
| for (const line of content.split('\n').filter(l => l.trim()).slice(0, maxLines)) { | ||
| try { | ||
| const entry = JSON.parse(line); | ||
| if (entry.cwd) return entry.cwd; | ||
| if (entry.projectPath) return entry.projectPath; | ||
| } catch {} | ||
| } | ||
| } catch {} | ||
| return null; | ||
| } | ||
| /** | ||
| * Parses a JSON request body with size limit. | ||
| * Returns { body } on success, { error, status } on failure. | ||
| */ | ||
| async function parseJsonBody(req, maxSize) { | ||
| const chunks = []; | ||
| let size = 0; | ||
| for await (const chunk of req) { | ||
| size += chunk.length; | ||
| if (size > maxSize) return { error: 'Request body too large', status: 413 }; | ||
| chunks.push(chunk); | ||
| } | ||
| try { | ||
| return { body: JSON.parse(Buffer.concat(chunks).toString()) }; | ||
| } catch { | ||
| return { error: 'Invalid JSON', status: 400 }; | ||
| } | ||
| } | ||
| /** | ||
| * Validates that a path is a legitimate project directory under the user's home. | ||
| * Resolves symlinks to prevent path traversal attacks. | ||
| */ | ||
| async function validateProjectPath(inputPath) { | ||
| if (!inputPath || typeof inputPath !== 'string') return null; | ||
| try { | ||
| const canonical = await realpath(resolve(normalize(inputPath))); | ||
| const home = await realpath(homedir()); | ||
| if (!canonical.startsWith(home + '/') && canonical !== home) return null; | ||
| await access(canonical, constants.R_OK); | ||
| return canonical; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function sendSSE(res, event, data) { | ||
| res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); | ||
| } | ||
| function sendJsonError(res, status, error) { | ||
| res.writeHead(status, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error })); | ||
| } | ||
| function sendJson(res, data, status = 200) { | ||
| res.writeHead(status, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify(data)); | ||
| } | ||
| function log(message) { | ||
| console.log(`[${new Date().toISOString()}] ${message}`); | ||
| } | ||
| // ============================================================================= | ||
| // Project Discovery | ||
@@ -159,6 +219,7 @@ // ============================================================================= | ||
| /** | ||
| * Discovers project paths from the Claude session store (~/.claude/projects/). | ||
| * Each subdirectory represents a project. We extract the original path by | ||
| * reading sessions-index.json or the first JSONL file's metadata. | ||
| * This catches projects that are too deep for the filesystem scan. | ||
| * Discovers all known project paths from ~/.claude/projects/. | ||
| * Each subdirectory represents a project. We extract the real path from | ||
| * sessions-index.json or JSONL session metadata. Projects whose stored path | ||
| * no longer exists on disk are skipped (run a Claude session from the new | ||
| * location to re-register it). | ||
| */ | ||
@@ -177,63 +238,30 @@ async function discoverProjectsFromSessionStore() { | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory() || entry.name === '.' || entry.name === '..') continue; | ||
| if (!entry.isDirectory()) continue; | ||
| const dirPath = join(claudeProjectsDir, entry.name); | ||
| // Try sessions-index.json first for the original path | ||
| const indexPath = join(dirPath, 'sessions-index.json'); | ||
| // Try sessions-index.json first | ||
| try { | ||
| const content = await readFile(indexPath, 'utf-8'); | ||
| const data = JSON.parse(content); | ||
| if (data.originalPath) { | ||
| projects.add(data.originalPath); | ||
| continue; | ||
| } | ||
| const data = JSON.parse(await readFile(join(dirPath, 'sessions-index.json'), 'utf-8')); | ||
| if (data.originalPath) { projects.add(data.originalPath); continue; } | ||
| } catch {} | ||
| // Fall back to reading first JSONL file for cwd/projectPath | ||
| // Fall back to extracting cwd from JSONL files | ||
| let cwd = null; | ||
| try { | ||
| const files = await readdir(dirPath); | ||
| const jsonlFile = files.find(f => f.endsWith('.jsonl')); | ||
| if (jsonlFile) { | ||
| const content = await readFile(join(dirPath, jsonlFile), 'utf-8'); | ||
| const firstLine = content.split('\n').find(l => l.trim()); | ||
| if (firstLine) { | ||
| const parsed = JSON.parse(firstLine); | ||
| if (parsed.cwd) { projects.add(parsed.cwd); continue; } | ||
| if (parsed.projectPath) { projects.add(parsed.projectPath); continue; } | ||
| } | ||
| for (const file of (await readdir(dirPath)).filter(f => f.endsWith('.jsonl'))) { | ||
| cwd = await extractCwdFromJsonl(join(dirPath, file)); | ||
| if (cwd) break; | ||
| } | ||
| } catch {} | ||
| } | ||
| return Array.from(projects).sort(); | ||
| } | ||
| if (!cwd) continue; | ||
| /** | ||
| * Validates that a path is a legitimate Claude project directory. | ||
| * Prevents path traversal (including symlink attacks) and ensures directory exists. | ||
| */ | ||
| async function validateProjectPath(inputPath) { | ||
| if (!inputPath || typeof inputPath !== 'string') { | ||
| return null; | ||
| // Only include projects whose path still exists on disk | ||
| try { | ||
| await fsStat(cwd); | ||
| projects.add(cwd); | ||
| } catch {} | ||
| } | ||
| try { | ||
| // Normalize, resolve to absolute path, then resolve symlinks to canonical path | ||
| // This prevents symlink-based path traversal attacks | ||
| const resolved = resolve(normalize(inputPath)); | ||
| const canonical = await realpath(resolved); | ||
| // Security: ensure canonical path is under home directory | ||
| const home = await realpath(homedir()); | ||
| if (!canonical.startsWith(home + '/') && canonical !== home) { | ||
| return null; | ||
| } | ||
| // Verify directory is accessible | ||
| await access(canonical, constants.R_OK); | ||
| return canonical; | ||
| } catch { | ||
| // Path doesn't exist, isn't accessible, or symlink resolution failed | ||
| return null; | ||
| } | ||
| return Array.from(projects).sort(); | ||
| } | ||
@@ -246,13 +274,4 @@ | ||
| /** | ||
| * Encodes a project path to the format used by Claude Code for session storage. | ||
| * Replaces all non-alphanumeric characters with `-` | ||
| * Example: /Users/foo.bar/project -> -Users-foo-bar-project | ||
| */ | ||
| function encodeProjectPath(projectPath) { | ||
| return projectPath.replace(/[^a-zA-Z0-9]/g, '-'); | ||
| } | ||
| /** | ||
| * Builds session metadata by scanning JSONL files in a project directory. | ||
| * Used as a fallback when sessions-index.json doesn't exist. | ||
| * Skips orphaned files that contain only file-history-snapshots. | ||
| */ | ||
@@ -262,3 +281,3 @@ async function buildSessionsFromFiles(projectDirPath) { | ||
| try { | ||
| files = await readdir(projectDirPath); | ||
| files = (await readdir(projectDirPath)).filter(f => f.endsWith('.jsonl')); | ||
| } catch { | ||
@@ -268,7 +287,5 @@ return []; | ||
| const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); | ||
| const sessions = []; | ||
| for (const file of jsonlFiles) { | ||
| const sessionId = file.replace('.jsonl', ''); | ||
| for (const file of files) { | ||
| const filePath = join(projectDirPath, file); | ||
@@ -278,9 +295,6 @@ | ||
| const stat = await fsStat(filePath); | ||
| // Read first few lines to extract metadata | ||
| const content = await readFile(filePath, 'utf-8'); | ||
| const lines = content.split('\n').filter(l => l.trim()).slice(0, 10); | ||
| let firstPrompt = '', created = '', modified = '', gitBranch = '', projectPath = ''; | ||
| let summary = ''; | ||
| let firstPrompt = '', summary = '', created = '', gitBranch = '', projectPath = ''; | ||
@@ -290,7 +304,6 @@ for (const line of lines) { | ||
| const entry = JSON.parse(line); | ||
| if (entry.type === 'summary') summary = entry.summary || ''; | ||
| if (entry.type === 'summary' && !summary) summary = entry.summary || ''; | ||
| if (entry.type === 'user' && !firstPrompt) { | ||
| firstPrompt = typeof entry.message?.content === 'string' | ||
| ? entry.message.content.slice(0, 100) | ||
| : ''; | ||
| ? entry.message.content.slice(0, 100) : ''; | ||
| created = entry.timestamp || ''; | ||
@@ -303,8 +316,6 @@ } | ||
| // Skip sessions with no conversation content (e.g. orphaned file-history-snapshot files) | ||
| // These can't be resumed by Claude CLI | ||
| if (!firstPrompt && !summary) continue; | ||
| sessions.push({ | ||
| sessionId, | ||
| sessionId: file.replace('.jsonl', ''), | ||
| fullPath: filePath, | ||
@@ -319,5 +330,3 @@ fileMtime: stat.mtimeMs, | ||
| }); | ||
| } catch { | ||
| // Skip files we can't read | ||
| } | ||
| } catch {} | ||
| } | ||
@@ -329,18 +338,12 @@ | ||
| /** | ||
| * Gets the sessions index for a project. | ||
| * Returns array of session entries, or empty array if not found. | ||
| * Falls back to scanning JSONL files when sessions-index.json doesn't exist. | ||
| * Gets sessions for a project, preferring sessions-index.json, | ||
| * falling back to JSONL file scanning. | ||
| */ | ||
| async function getSessionsIndex(projectPath) { | ||
| const encodedPath = encodeProjectPath(projectPath); | ||
| const projectDirPath = join(homedir(), '.claude', 'projects', encodedPath); | ||
| const sessionsIndexPath = join(projectDirPath, 'sessions-index.json'); | ||
| const projectDirPath = join(homedir(), '.claude', 'projects', encodeProjectPath(projectPath)); | ||
| try { | ||
| const content = await readFile(sessionsIndexPath, 'utf-8'); | ||
| const data = JSON.parse(content); | ||
| // sessions-index.json has structure: { version: 1, entries: [...] } | ||
| const data = JSON.parse(await readFile(join(projectDirPath, 'sessions-index.json'), 'utf-8')); | ||
| return data.entries || (Array.isArray(data) ? data : []); | ||
| } catch (err) { | ||
| // File doesn't exist or is invalid - fall back to scanning JSONL files | ||
| } catch { | ||
| return buildSessionsFromFiles(projectDirPath); | ||
@@ -351,5 +354,5 @@ } | ||
| /** | ||
| * Finds the correct project directory for a given sessionId. | ||
| * Searches the base project dir and all subdirectory project dirs. | ||
| * Returns the projectPath (cwd) from the session metadata, or null if not found. | ||
| * Finds the correct working directory for a session that may belong to a | ||
| * subdirectory project (e.g., session created in web-ui/static-apps/foo | ||
| * while the user selected web-ui as the active project). | ||
| */ | ||
@@ -361,31 +364,17 @@ async function resolveSessionProjectPath(baseCwd, sessionId) { | ||
| // Check the base project dir first | ||
| // Check the base project directory first | ||
| try { | ||
| await access(join(claudeProjectsDir, encodedBase, sessionFile), constants.R_OK); | ||
| return baseCwd; // Session is in the base project | ||
| return baseCwd; | ||
| } catch {} | ||
| // Search subdirectory project dirs | ||
| // Search subdirectory project directories | ||
| try { | ||
| const allDirs = await readdir(claudeProjectsDir); | ||
| for (const dir of allDirs) { | ||
| if (dir.startsWith(encodedBase) && dir !== encodedBase) { | ||
| const filePath = join(claudeProjectsDir, dir, sessionFile); | ||
| try { | ||
| await access(filePath, constants.R_OK); | ||
| // Found it — read the first few lines to find cwd | ||
| // (first line may be file-history-snapshot without cwd) | ||
| const content = await readFile(filePath, 'utf-8'); | ||
| const lines = content.split('\n').filter(l => l.trim()).slice(0, 10); | ||
| for (const line of lines) { | ||
| try { | ||
| const entry = JSON.parse(line); | ||
| if (entry.cwd) return entry.cwd; | ||
| if (entry.projectPath) return entry.projectPath; | ||
| } catch {} | ||
| } | ||
| // Couldn't extract path from content, but file exists here | ||
| return baseCwd; | ||
| } catch {} | ||
| } | ||
| for (const dir of await readdir(claudeProjectsDir)) { | ||
| if (!dir.startsWith(encodedBase + '-')) continue; | ||
| const filePath = join(claudeProjectsDir, dir, sessionFile); | ||
| try { | ||
| await access(filePath, constants.R_OK); | ||
| return await extractCwdFromJsonl(filePath) || baseCwd; | ||
| } catch {} | ||
| } | ||
@@ -398,10 +387,2 @@ } catch {} | ||
| // ============================================================================= | ||
| // SSE Helpers | ||
| // ============================================================================= | ||
| function sendSSE(res, event, data) { | ||
| res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); | ||
| } | ||
| // ============================================================================= | ||
| // Request Handlers | ||
@@ -411,4 +392,3 @@ // ============================================================================= | ||
| function handleHealth(req, res) { | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ | ||
| sendJson(res, { | ||
| status: 'ok', | ||
@@ -418,52 +398,17 @@ agent: PACKAGE_NAME, | ||
| timestamp: new Date().toISOString(), | ||
| })); | ||
| }); | ||
| } | ||
| async function handleProjects(req, res, config) { | ||
| try { | ||
| // Discover projects only from the session store (~/.claude/projects/) | ||
| // This avoids scanning the entire home directory for security | ||
| const projects = await discoverProjectsFromSessionStore(); | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ projects })); | ||
| } catch (err) { | ||
| res.writeHead(500, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Failed to scan projects' })); | ||
| } | ||
| async function handleProjects(req, res) { | ||
| const projects = await discoverProjectsFromSessionStore(); | ||
| sendJson(res, { projects }); | ||
| } | ||
| async function handleSessions(req, res, config) { | ||
| async function handleSessions(req, res) { | ||
| const url = new URL(req.url, `http://${req.headers.host}`); | ||
| const cwd = url.searchParams.get('cwd'); | ||
| const projectPath = await validateProjectPath(url.searchParams.get('cwd')); | ||
| if (!projectPath) return sendJsonError(res, 400, 'Invalid or missing cwd parameter'); | ||
| const projectPath = await validateProjectPath(cwd); | ||
| if (!projectPath) { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Invalid or missing cwd parameter' })); | ||
| return; | ||
| } | ||
| // Get sessions for the requested project | ||
| const sessions = await getSessionsIndex(projectPath); | ||
| // Also include sessions from subdirectory projects | ||
| // e.g. for web-ui, also find web-ui/static-apps/claude-remote sessions | ||
| const encodedPrefix = encodeProjectPath(projectPath); | ||
| const claudeProjectsDir = join(homedir(), '.claude', 'projects'); | ||
| try { | ||
| const allProjectDirs = await readdir(claudeProjectsDir); | ||
| for (const dir of allProjectDirs) { | ||
| if (dir.startsWith(encodedPrefix) && dir !== encodedPrefix) { | ||
| // This is a subdirectory project - include its sessions too | ||
| const subDirPath = join(claudeProjectsDir, dir); | ||
| const subSessions = await buildSessionsFromFiles(subDirPath); | ||
| sessions.push(...subSessions); | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore errors reading project dirs | ||
| } | ||
| // Sort all sessions by modified date (most recent first) | ||
| sessions.sort((a, b) => { | ||
@@ -475,54 +420,25 @@ const aTime = a.fileMtime || new Date(a.modified || 0).getTime(); | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ sessions })); | ||
| sendJson(res, { sessions }); | ||
| } | ||
| async function handlePrompt(req, res, config) { | ||
| // Collect request body with size limit | ||
| const chunks = []; | ||
| let size = 0; | ||
| const { body, error, status } = await parseJsonBody(req, config.maxBodySize); | ||
| if (error) return sendJsonError(res, status, error); | ||
| for await (const chunk of req) { | ||
| size += chunk.length; | ||
| if (size > config.maxBodySize) { | ||
| res.writeHead(413, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Request body too large' })); | ||
| return; | ||
| } | ||
| chunks.push(chunk); | ||
| } | ||
| const { prompt, cwd, sessionId } = body; | ||
| // Parse JSON | ||
| let body; | ||
| try { | ||
| body = JSON.parse(Buffer.concat(chunks).toString()); | ||
| } catch { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Invalid JSON' })); | ||
| return; | ||
| } | ||
| // Validate prompt | ||
| const { prompt, cwd, sessionId } = body; | ||
| if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Prompt is required' })); | ||
| return; | ||
| return sendJsonError(res, 400, 'Prompt is required'); | ||
| } | ||
| // Validate sessionId if provided (must be UUID format) | ||
| if (sessionId !== undefined) { | ||
| const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | ||
| if (typeof sessionId !== 'string' || !uuidPattern.test(sessionId)) { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Invalid sessionId format (must be UUID)' })); | ||
| return; | ||
| return sendJsonError(res, 400, 'Invalid sessionId format (must be UUID)'); | ||
| } | ||
| } | ||
| // Validate and resolve working directory | ||
| let workingDir = await validateProjectPath(cwd) || process.cwd(); | ||
| // If resuming a specific session, resolve the correct cwd for it. | ||
| // Sessions from subdirectory projects need their own cwd, not the parent's. | ||
| // Resolve the correct cwd for the session — it may belong to a subdirectory project | ||
| if (sessionId && cwd) { | ||
@@ -541,36 +457,23 @@ const resolvedPath = await resolveSessionProjectPath(workingDir, sessionId); | ||
| 'Connection': 'keep-alive', | ||
| 'X-Accel-Buffering': 'no', // Disable nginx buffering | ||
| 'X-Accel-Buffering': 'no', | ||
| }); | ||
| res.flushHeaders(); | ||
| sendSSE(res, 'start', { timestamp: new Date().toISOString() }); | ||
| // Spawn Claude Code with streaming JSON output | ||
| // Use --resume <sessionId> if provided, otherwise --continue for most recent session | ||
| // Build Claude CLI arguments | ||
| const sessionArg = sessionId ? ['--resume', sessionId] : ['--continue']; | ||
| const args = ['-p', '--output-format', 'stream-json', '--verbose', ...sessionArg, prompt.trim()]; | ||
| console.log(`[${new Date().toISOString()}] Command: claude ${args.map(a => a.includes(' ') || a.includes('\n') ? JSON.stringify(a) : a).join(' ')}`); | ||
| console.log(`[${new Date().toISOString()}] Working directory: ${workingDir}`); | ||
| log(`Command: claude ${args.map(a => a.includes(' ') || a.includes('\n') ? JSON.stringify(a) : a).join(' ')}`); | ||
| log(`Working directory: ${workingDir}`); | ||
| // Build a safe environment - only pass through vars Claude Code needs | ||
| // This blocks dangerous vars like NODE_OPTIONS, LD_PRELOAD, DYLD_*, etc. | ||
| // Build a safe environment — only pass through vars Claude Code needs. | ||
| // Blocks dangerous vars like NODE_OPTIONS, LD_PRELOAD, DYLD_*, etc. | ||
| const safeEnv = {}; | ||
| const allowedPrefixes = [ | ||
| 'ANTHROPIC_', // API config (key, model, timeouts, headers) | ||
| 'CLAUDE_', // Claude Code config | ||
| 'AWS_', // Bedrock authentication | ||
| 'GOOGLE_', // Vertex AI authentication | ||
| 'VERTEX_', // Vertex region overrides | ||
| 'OTEL_', // OpenTelemetry config | ||
| ]; | ||
| const allowedPrefixes = ['ANTHROPIC_', 'CLAUDE_', 'AWS_', 'GOOGLE_', 'VERTEX_', 'OTEL_']; | ||
| const allowedExact = new Set([ | ||
| // Essential system vars | ||
| 'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE', | ||
| // Temp directories | ||
| 'TMPDIR', 'TMP', 'TEMP', | ||
| // Network (proxy support for corporate environments) | ||
| 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', | ||
| 'http_proxy', 'https_proxy', 'no_proxy', // lowercase variants | ||
| // XDG directories | ||
| 'http_proxy', 'https_proxy', 'no_proxy', | ||
| 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', | ||
@@ -583,3 +486,2 @@ ]); | ||
| } | ||
| // Force disable colors for clean output parsing | ||
| safeEnv.FORCE_COLOR = '0'; | ||
@@ -594,6 +496,5 @@ safeEnv.NO_COLOR = '1'; | ||
| console.log(`[${new Date().toISOString()}] Spawned Claude (PID: ${claude.pid})`); | ||
| log(`Spawned Claude (PID: ${claude.pid})`); | ||
| claude.stdout.on('data', (chunk) => { | ||
| // Pass through raw stream-json output (JSONL format) | ||
| sendSSE(res, 'chunk', { content: chunk.toString() }); | ||
@@ -609,3 +510,3 @@ }); | ||
| claude.on('close', (code) => { | ||
| console.log(`[${new Date().toISOString()}] Claude exited (code: ${code})`); | ||
| log(`Claude exited (code: ${code})`); | ||
| sendSSE(res, 'done', { code, timestamp: new Date().toISOString() }); | ||
@@ -621,6 +522,5 @@ res.end(); | ||
| // Clean up on client disconnect | ||
| res.on('close', () => { | ||
| if (!claude.killed) { | ||
| console.log(`[${new Date().toISOString()}] Client disconnected, terminating Claude`); | ||
| log('Client disconnected, terminating Claude'); | ||
| claude.kill('SIGTERM'); | ||
@@ -631,28 +531,16 @@ } | ||
| async function handleGetSettings(req, res, config) { | ||
| async function handleGetSettings(req, res) { | ||
| const url = new URL(req.url, `http://${req.headers.host}`); | ||
| const cwd = url.searchParams.get('cwd'); | ||
| const projectPath = await validateProjectPath(url.searchParams.get('cwd')); | ||
| if (!projectPath) return sendJsonError(res, 400, 'Invalid or missing cwd parameter'); | ||
| const projectPath = await validateProjectPath(cwd); | ||
| if (!projectPath) { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Invalid or missing cwd parameter' })); | ||
| return; | ||
| } | ||
| const settingsPath = join(projectPath, '.claude', 'settings.local.json'); | ||
| try { | ||
| const content = await readFile(settingsPath, 'utf-8'); | ||
| const settings = JSON.parse(content); | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify(settings)); | ||
| sendJson(res, JSON.parse(await readFile(settingsPath, 'utf-8'))); | ||
| } catch (err) { | ||
| if (err.code === 'ENOENT') { | ||
| // Return empty settings if file doesn't exist | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ permissions: { allow: [], deny: [] } })); | ||
| sendJson(res, { permissions: { allow: [], deny: [] } }); | ||
| } else { | ||
| res.writeHead(500, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Failed to read settings' })); | ||
| sendJsonError(res, 500, 'Failed to read settings'); | ||
| } | ||
@@ -662,6 +550,2 @@ } | ||
| /** | ||
| * Validates the structure of settings object. | ||
| * Returns { valid: true } or { valid: false, error: string } | ||
| */ | ||
| function validateSettingsSchema(settings) { | ||
@@ -671,3 +555,2 @@ if (!settings || typeof settings !== 'object') { | ||
| } | ||
| if (!settings.permissions || typeof settings.permissions !== 'object') { | ||
@@ -678,17 +561,10 @@ return { valid: false, error: 'Settings must have a permissions object' }; | ||
| const { allow, deny } = settings.permissions; | ||
| if (!Array.isArray(allow)) return { valid: false, error: 'permissions.allow must be an array' }; | ||
| if (!Array.isArray(deny)) return { valid: false, error: 'permissions.deny must be an array' }; | ||
| if (!Array.isArray(allow)) { | ||
| return { valid: false, error: 'permissions.allow must be an array' }; | ||
| } | ||
| if (!Array.isArray(deny)) { | ||
| return { valid: false, error: 'permissions.deny must be an array' }; | ||
| } | ||
| // Validate each permission is a non-empty string matching expected format | ||
| const permissionPattern = /^(Edit|Bash|Read|Write|WebFetch|mcp__[\w-]+__[\w-]+)\(.+\)$/; | ||
| for (const perm of allow) { | ||
| for (const perm of [...allow, ...deny]) { | ||
| if (typeof perm !== 'string' || perm.length === 0) { | ||
| return { valid: false, error: `Invalid permission in allow: ${JSON.stringify(perm)}` }; | ||
| return { valid: false, error: `Invalid permission: ${JSON.stringify(perm)}` }; | ||
| } | ||
@@ -700,11 +576,2 @@ if (!permissionPattern.test(perm)) { | ||
| for (const perm of deny) { | ||
| if (typeof perm !== 'string' || perm.length === 0) { | ||
| return { valid: false, error: `Invalid permission in deny: ${JSON.stringify(perm)}` }; | ||
| } | ||
| if (!permissionPattern.test(perm)) { | ||
| return { valid: false, error: `Permission does not match expected format: ${perm}` }; | ||
| } | ||
| } | ||
| return { valid: true }; | ||
@@ -714,40 +581,12 @@ } | ||
| async function handlePostSettings(req, res, config) { | ||
| // Collect request body | ||
| const chunks = []; | ||
| let size = 0; | ||
| const { body, error, status } = await parseJsonBody(req, config.maxBodySize); | ||
| if (error) return sendJsonError(res, status, error); | ||
| for await (const chunk of req) { | ||
| size += chunk.length; | ||
| if (size > config.maxBodySize) { | ||
| res.writeHead(413, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Request body too large' })); | ||
| return; | ||
| } | ||
| chunks.push(chunk); | ||
| } | ||
| let body; | ||
| try { | ||
| body = JSON.parse(Buffer.concat(chunks).toString()); | ||
| } catch { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Invalid JSON' })); | ||
| return; | ||
| } | ||
| const { cwd, settings } = body; | ||
| const projectPath = await validateProjectPath(cwd); | ||
| if (!projectPath) { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Invalid or missing cwd' })); | ||
| return; | ||
| } | ||
| if (!projectPath) return sendJsonError(res, 400, 'Invalid or missing cwd'); | ||
| const validation = validateSettingsSchema(settings); | ||
| if (!validation.valid) { | ||
| res.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: validation.error })); | ||
| return; | ||
| } | ||
| if (!validation.valid) return sendJsonError(res, 400, validation.error); | ||
@@ -758,24 +597,12 @@ const claudeDir = join(projectPath, '.claude'); | ||
| try { | ||
| // Ensure .claude directory exists | ||
| await mkdir(claudeDir, { recursive: true }); | ||
| // Write settings with pretty formatting | ||
| await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n'); | ||
| console.log(`[${new Date().toISOString()}] Updated settings: ${settingsPath}`); | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ success: true })); | ||
| log(`Updated settings: ${settingsPath}`); | ||
| sendJson(res, { success: true }); | ||
| } catch (err) { | ||
| console.error(`[${new Date().toISOString()}] Failed to write settings: ${err.message}`); | ||
| res.writeHead(500, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Failed to write settings' })); | ||
| console.error(`[error] Failed to write settings: ${err.message}`); | ||
| sendJsonError(res, 500, 'Failed to write settings'); | ||
| } | ||
| } | ||
| function handleNotFound(req, res) { | ||
| res.writeHead(404, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Not found' })); | ||
| } | ||
| // ============================================================================= | ||
@@ -789,51 +616,38 @@ // Server | ||
| const server = createServer(async (req, res) => { | ||
| // CORS handling | ||
| const origin = validateOrigin(req, config); | ||
| setCorsHeaders(res, origin); | ||
| try { | ||
| const reqOrigin = validateOrigin(req, config); | ||
| setCorsHeaders(res, reqOrigin); | ||
| // Preflight | ||
| if (req.method === 'OPTIONS') { | ||
| res.writeHead(204); | ||
| res.end(); | ||
| return; | ||
| } | ||
| // Reject requests from untrusted browser origins. | ||
| // A missing Origin header means same-origin (curl, non-browser) — allow. | ||
| // A present but untrusted Origin means a cross-origin browser request — block. | ||
| // Without this, a malicious webpage could trigger Claude CLI via no-cors fetch. | ||
| if (req.headers.origin && !reqOrigin) { | ||
| sendJsonError(res, 403, 'Forbidden'); | ||
| return; | ||
| } | ||
| // Route requests | ||
| const { method, url } = req; | ||
| if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } | ||
| if (method === 'GET' && url === '/health') { | ||
| return handleHealth(req, res); | ||
| } | ||
| const { method, url } = req; | ||
| if (method === 'GET' && url === '/projects') { | ||
| return handleProjects(req, res, config); | ||
| } | ||
| if (method === 'GET' && url === '/health') return handleHealth(req, res); | ||
| if (method === 'GET' && url === '/projects') return handleProjects(req, res); | ||
| if (method === 'GET' && url.startsWith('/sessions')) return handleSessions(req, res); | ||
| if (method === 'POST' && url === '/prompt') return handlePrompt(req, res, config); | ||
| if (method === 'GET' && url.startsWith('/settings')) return handleGetSettings(req, res); | ||
| if (method === 'POST' && url === '/settings') return handlePostSettings(req, res, config); | ||
| if (method === 'GET' && url.startsWith('/sessions')) { | ||
| return handleSessions(req, res, config); | ||
| sendJsonError(res, 404, 'Not found'); | ||
| } catch (err) { | ||
| console.error(`[error] Unhandled: ${err.stack || err.message}`); | ||
| if (!res.headersSent) { | ||
| sendJsonError(res, 500, 'Internal server error'); | ||
| } | ||
| } | ||
| if (method === 'POST' && url === '/prompt') { | ||
| return handlePrompt(req, res, config); | ||
| } | ||
| if (method === 'GET' && url.startsWith('/settings')) { | ||
| return handleGetSettings(req, res, config); | ||
| } | ||
| if (method === 'POST' && url === '/settings') { | ||
| return handlePostSettings(req, res, config); | ||
| } | ||
| return handleNotFound(req, res); | ||
| }); | ||
| // Graceful shutdown | ||
| function shutdown(signal) { | ||
| console.log(`\n[${new Date().toISOString()}] Received ${signal}, shutting down...`); | ||
| server.close(() => { | ||
| console.log(`[${new Date().toISOString()}] Server closed`); | ||
| process.exit(0); | ||
| }); | ||
| // Force exit after 5 seconds | ||
| log(`Received ${signal}, shutting down...`); | ||
| server.close(() => { log('Server closed'); process.exit(0); }); | ||
| setTimeout(() => process.exit(1), 5000); | ||
@@ -845,3 +659,2 @@ } | ||
| // Start server | ||
| server.on('error', (err) => { | ||
@@ -848,0 +661,0 @@ if (err.code === 'EADDRINUSE') { |
+1
-1
| { | ||
| "name": "ccgate", | ||
| "version": "1.2.0", | ||
| "version": "1.3.0", | ||
| "description": "Local gateway for controlling Claude Code from a webpage", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+3
-1
@@ -56,3 +56,3 @@ # ccgate | ||
| Returns session metadata including `sessionId`, `firstPrompt`, `created`, `modified`, `gitBranch`, and `projectPath`. Reads from `sessions-index.json` when available, falling back to scanning JSONL session files. Also includes sessions from subdirectory projects (e.g., sessions in child paths under the requested project). | ||
| Returns session metadata including `sessionId`, `firstPrompt`, `created`, `modified`, `gitBranch`, and `projectPath`. Reads from `sessions-index.json` when available, falling back to scanning JSONL session files. | ||
@@ -115,3 +115,5 @@ ### `POST /prompt` | ||
| - **Localhost binding**: Only listens on `127.0.0.1`, not accessible from other machines | ||
| - **Origin enforcement**: Requests with an untrusted `Origin` header are rejected with 403 (prevents malicious webpages from triggering CLI commands via `no-cors` fetch) | ||
| - **CORS restrictions**: Only allows requests from `localhost`, `127.0.0.1`, and the origin specified on the command line | ||
| - **Scoped filesystem reads**: Only reads session data from `~/.claude/projects/` — no home directory scanning | ||
| - **Path validation**: The `cwd` parameter must resolve to a path under `$HOME`; symlinks are resolved to prevent traversal attacks | ||
@@ -118,0 +120,0 @@ - **Environment filtering**: Only safe environment variables are passed to the Claude subprocess (blocks `NODE_OPTIONS`, `LD_PRELOAD`, etc.) |
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
152
1.33%28219
-17.56%553
-23.19%3
50%