Launch Week Day 2: Introducing Reports: An Extensible Reporting Framework for Socket Data.Learn More
Socket
Book a DemoSign in
Socket

ccgate

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ccgate - npm Package Compare versions

Comparing version
1.2.0
to
1.3.0
+216
-403
ccgate.mjs

@@ -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') {

{
"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",

@@ -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.)