UNPKG

cui-server

Version:

Web UI Agent Platform based on Claude Code

348 lines 13.9 kB
import * as fs from 'fs/promises'; import * as path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import { existsSync, constants } from 'fs'; import ignore from 'ignore'; import { CUIError } from '../types/index.js'; import { createLogger } from './logger.js'; const execAsync = promisify(exec); /** * Service for secure file system operations */ export class FileSystemService { logger; maxFileSize = 10 * 1024 * 1024; // 10MB default allowedBasePaths = []; // Empty means all paths allowed constructor(maxFileSize, allowedBasePaths) { this.logger = createLogger('FileSystemService'); if (maxFileSize !== undefined) { this.maxFileSize = maxFileSize; } if (allowedBasePaths) { this.allowedBasePaths = allowedBasePaths.map(p => path.normalize(p)); } } /** * List directory contents with security checks */ async listDirectory(requestedPath, recursive = false, respectGitignore = false) { this.logger.debug('List directory requested', { requestedPath, recursive, respectGitignore }); try { // Validate and normalize path const safePath = await this.validatePath(requestedPath); // Check if path exists and is a directory const stats = await fs.stat(safePath); if (!stats.isDirectory()) { throw new CUIError('NOT_A_DIRECTORY', `Path is not a directory: ${requestedPath}`, 400); } // Initialize gitignore if requested let ig = null; if (respectGitignore) { ig = await this.loadGitignore(safePath); } // Get entries const entries = recursive ? await this.listDirectoryRecursive(safePath, safePath, ig) : await this.listDirectoryFlat(safePath, ig); // Sort entries: directories first, then by name entries.sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); this.logger.debug('Directory listed successfully', { path: safePath, entryCount: entries.length, recursive, respectGitignore }); return { path: safePath, entries, total: entries.length }; } catch (error) { if (error instanceof CUIError) { throw error; } const errorCode = error.code; if (errorCode === 'ENOENT') { throw new CUIError('PATH_NOT_FOUND', `Path not found: ${requestedPath}`, 404); } else if (errorCode === 'EACCES') { throw new CUIError('ACCESS_DENIED', `Access denied to path: ${requestedPath}`, 403); } this.logger.error('Error listing directory', error, { requestedPath }); throw new CUIError('LIST_DIRECTORY_FAILED', `Failed to list directory: ${error}`, 500); } } /** * Read file contents with security checks */ async readFile(requestedPath) { this.logger.debug('Read file requested', { requestedPath }); try { // Validate and normalize path const safePath = await this.validatePath(requestedPath); // Check if path exists and is a file const stats = await fs.stat(safePath); if (!stats.isFile()) { throw new CUIError('NOT_A_FILE', `Path is not a file: ${requestedPath}`, 400); } // Check file size if (stats.size > this.maxFileSize) { throw new CUIError('FILE_TOO_LARGE', `File size (${stats.size} bytes) exceeds maximum allowed size (${this.maxFileSize} bytes)`, 400); } // Read file content const content = await fs.readFile(safePath, 'utf-8'); // Check if content is valid UTF-8 text if (!this.isValidUtf8(content)) { throw new CUIError('BINARY_FILE', 'File appears to be binary or not valid UTF-8', 400); } this.logger.debug('File read successfully', { path: safePath, size: stats.size }); return { path: safePath, content, size: stats.size, lastModified: stats.mtime.toISOString(), encoding: 'utf-8' }; } catch (error) { if (error instanceof CUIError) { throw error; } const errorCode = error.code; if (errorCode === 'ENOENT') { throw new CUIError('FILE_NOT_FOUND', `File not found: ${requestedPath}`, 404); } else if (errorCode === 'EACCES') { throw new CUIError('ACCESS_DENIED', `Access denied to file: ${requestedPath}`, 403); } this.logger.error('Error reading file', error, { requestedPath }); throw new CUIError('READ_FILE_FAILED', `Failed to read file: ${error}`, 500); } } /** * Validate and normalize a path to prevent path traversal attacks */ async validatePath(requestedPath) { // Require absolute paths if (!path.isAbsolute(requestedPath)) { throw new CUIError('INVALID_PATH', 'Path must be absolute', 400); } // Check for path traversal attempts before normalization if (requestedPath.includes('..')) { this.logger.warn('Path traversal attempt detected', { requestedPath }); throw new CUIError('PATH_TRAVERSAL_DETECTED', 'Invalid path: path traversal detected', 400); } // Normalize the path to resolve . segments and clean up const normalizedPath = path.normalize(requestedPath); // Check against allowed base paths if configured if (this.allowedBasePaths.length > 0) { const isAllowed = this.allowedBasePaths.some(basePath => normalizedPath.startsWith(basePath)); if (!isAllowed) { this.logger.warn('Path outside allowed directories', { requestedPath, normalizedPath, allowedBasePaths: this.allowedBasePaths }); throw new CUIError('PATH_NOT_ALLOWED', 'Path is outside allowed directories', 403); } } // Additional security checks const segments = normalizedPath.split(path.sep); for (const segment of segments) { if (!segment) continue; // Check for hidden files/directories if (segment.startsWith('.')) { this.logger.warn('Hidden file/directory detected', { requestedPath, segment }); throw new CUIError('INVALID_PATH', 'Path contains hidden files/directories', 400); } // Check for null bytes if (segment.includes('\u0000')) { this.logger.warn('Null byte detected in path', { requestedPath, segment }); throw new CUIError('INVALID_PATH', 'Path contains null bytes', 400); } // Check for invalid characters if (/[<>:|?*]/.test(segment)) { this.logger.warn('Invalid characters detected in path', { requestedPath, segment }); throw new CUIError('INVALID_PATH', 'Path contains invalid characters', 400); } } this.logger.debug('Path validated successfully', { requestedPath, normalizedPath }); return normalizedPath; } /** * Check if content appears to be valid UTF-8 text */ isValidUtf8(content) { // Check for null bytes - common binary file indicator if (content.includes('\u0000')) { return false; } // Check for control characters (excluding tab, newline, and carriage return) for (let i = 0; i < content.length; i++) { const charCode = content.charCodeAt(i); // Allow tab (9), newline (10), and carriage return (13) // Reject other control characters (1-8, 11-12, 14-31) if ((charCode >= 1 && charCode <= 8) || (charCode >= 11 && charCode <= 12) || (charCode >= 14 && charCode <= 31)) { return false; } } return true; } /** * List directory contents without recursion */ async listDirectoryFlat(dirPath, ig) { const dirents = await fs.readdir(dirPath, { withFileTypes: true }); const entries = []; for (const dirent of dirents) { // Check gitignore BEFORE any expensive operations if (ig && ig.ignores(dirent.name)) { continue; } const fullPath = path.join(dirPath, dirent.name); const stats = await fs.stat(fullPath); entries.push({ name: dirent.name, type: dirent.isDirectory() ? 'directory' : 'file', size: dirent.isFile() ? stats.size : undefined, lastModified: stats.mtime.toISOString() }); } return entries; } /** * List directory contents recursively */ async listDirectoryRecursive(dirPath, basePath, ig) { const entries = []; async function traverse(currentPath) { const dirents = await fs.readdir(currentPath, { withFileTypes: true }); for (const dirent of dirents) { const fullPath = path.join(currentPath, dirent.name); const relativePath = path.relative(basePath, fullPath); // Check gitignore BEFORE any expensive operations if (ig && ig.ignores(relativePath)) { // Skip this entry entirely - don't stat, don't recurse into directories continue; } const stats = await fs.stat(fullPath); entries.push({ name: relativePath, type: dirent.isDirectory() ? 'directory' : 'file', size: dirent.isFile() ? stats.size : undefined, lastModified: stats.mtime.toISOString() }); // Recurse into subdirectories (already checked it's not ignored) if (dirent.isDirectory()) { await traverse(fullPath); } } } await traverse(dirPath); return entries; } /** * Load gitignore patterns from a directory and its parents */ async loadGitignore(dirPath) { const ig = ignore(); // Load .gitignore from the directory try { const gitignorePath = path.join(dirPath, '.gitignore'); const content = await fs.readFile(gitignorePath, 'utf-8'); ig.add(content); this.logger.debug('Loaded .gitignore', { path: gitignorePath }); } catch (error) { // .gitignore doesn't exist or can't be read - that's fine const errorCode = error.code; if (errorCode !== 'ENOENT') { this.logger.debug('Error reading .gitignore', { error, path: dirPath }); } } // Always ignore .git directory ig.add('.git'); return ig; } /** * Check if a directory is a git repository */ async isGitRepository(dirPath) { try { await execAsync('git rev-parse --git-dir', { cwd: dirPath }); return true; } catch (error) { this.logger.debug('Directory is not a git repository', { dirPath, error }); return false; } } /** * Get current git HEAD commit hash */ async getCurrentGitHead(dirPath) { try { const { stdout } = await execAsync('git rev-parse HEAD', { cwd: dirPath }); return stdout.trim(); } catch (error) { this.logger.debug('Failed to get git HEAD', { dirPath, error }); return null; } } /** * Validate that an executable exists and has executable permissions */ async validateExecutable(executablePath) { this.logger.debug('Validating executable', { executablePath }); try { // Check if file exists if (!existsSync(executablePath)) { throw new CUIError('EXECUTABLE_NOT_FOUND', `Executable not found: ${executablePath}`, 404); } // Check if file is executable try { await fs.access(executablePath, constants.X_OK); } catch (_error) { throw new CUIError('NOT_EXECUTABLE', `File exists but is not executable: ${executablePath}`, 403); } this.logger.debug('Executable validation successful', { executablePath }); } catch (error) { if (error instanceof CUIError) { throw error; } this.logger.error('Error validating executable', error, { executablePath }); throw new CUIError('EXECUTABLE_VALIDATION_FAILED', `Failed to validate executable: ${error}`, 500); } } } //# sourceMappingURL=file-system-service.js.map