import fs from "fs/promises"; import path from "path"; import os from 'os'; import { randomBytes } from 'crypto'; import { StringDecoder } from 'string_decoder'; import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; import { normalizePath, expandHome } from './path-utils.js'; import { isPathWithinAllowedDirectories } from './path-validation.js'; // Global allowed directories - set by the main module let allowedDirectories: string[] = []; // Function to set allowed directories from the main module export function setAllowedDirectories(directories: string[]): void { allowedDirectories = [...directories]; } // Function to get current allowed directories export function getAllowedDirectories(): string[] { return [...allowedDirectories]; } // Type definitions interface FileInfo { size: number; created: Date; modified: Date; accessed: Date; isDirectory: boolean; isFile: boolean; permissions: string; } export interface SearchOptions { excludePatterns?: string[]; } export interface SearchResult { path: string; isDirectory: boolean; } // Pure Utility Functions export function formatSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; if (bytes <= 0) return '0 B'; const i = Math.floor(Math.log(bytes) / Math.log(1024)); if (i < 0 || i === 0) return `${bytes} ${units[0]}`; const unitIndex = Math.min(i, units.length - 1); return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`; } export function normalizeLineEndings(text: string): string { return text.replace(/\r\n/g, '\n'); } export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { // Ensure consistent line endings for diff const normalizedOriginal = normalizeLineEndings(originalContent); const normalizedNew = normalizeLineEndings(newContent); return createTwoFilesPatch( filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified' ); } // Helper function to resolve relative paths against allowed directories function resolveRelativePathAgainstAllowedDirectories(relativePath: string): string { if (allowedDirectories.length === 0) { // Fallback to process.cwd() if no allowed directories are set return path.resolve(process.cwd(), relativePath); } // Try to resolve relative path against each allowed directory for (const allowedDir of allowedDirectories) { const candidate = path.resolve(allowedDir, relativePath); const normalizedCandidate = normalizePath(candidate); // Check if the resulting path lies within any allowed directory if (isPathWithinAllowedDirectories(normalizedCandidate, allowedDirectories)) { return candidate; } } // If no valid resolution found, use the first allowed directory as base // This provides a consistent fallback behavior return path.resolve(allowedDirectories[0], relativePath); } // Security & Validation Functions async function resolveUnicodeEquivalentPath(absolutePath: string): Promise { const allowedDirectory = [...allowedDirectories] .sort((left, right) => right.length - left.length) .find(directory => isPathWithinAllowedDirectories(normalizePath(absolutePath), [directory])); if (!allowedDirectory) { return absolutePath; } let currentPath = await fs.realpath(allowedDirectory); const relativeParts = path.relative(allowedDirectory, absolutePath).split(path.sep).filter(Boolean); for (let index = 0; index < relativeParts.length; index++) { const requestedPart = relativeParts[index]; const entries = (await fs.readdir(currentPath)) ?? []; const exactMatch = entries.find(entry => entry === requestedPart); const equivalentMatches = exactMatch ? [exactMatch] : entries.filter(entry => entry.normalize('NFC') === requestedPart.normalize('NFC')); if (equivalentMatches.length > 1) { throw new Error(`Ambiguous Unicode path component: ${requestedPart}`); } if (equivalentMatches.length === 0) { // Nothing below this point exists yet, so there are no symlinks left to // resolve. currentPath is already realpath'd and inside an allowed // directory; append the missing tail so create_directory can mkdir -p it. return path.join(currentPath, ...relativeParts.slice(index)); } currentPath = await fs.realpath(path.join(currentPath, equivalentMatches[0])); if (!isPathWithinAllowedDirectories(normalizePath(currentPath), allowedDirectories)) { throw new Error(`Access denied - symlink target outside allowed directories: ${currentPath} not in ${allowedDirectories.join(', ')}`); } } return currentPath; } export async function validatePath(requestedPath: string): Promise { const expandedPath = expandHome(requestedPath); // Do not silently reinterpret a Windows drive path as a relative POSIX path. // This would create a literal filename such as `C:\\Users\\...` inside the // allowed root and report success for the wrong location. if (process.platform !== 'win32' && /^(?:[A-Za-z]:)(?:[\\/]|$)/.test(expandedPath)) { throw new Error(`Access denied - Windows-style path received on a POSIX host: ${requestedPath}`); } const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : resolveRelativePathAgainstAllowedDirectories(expandedPath); const normalizedRequested = normalizePath(absolute); // Security: Check if path is within allowed directories before any file operations const isAllowed = isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories); if (!isAllowed) { throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`); } // Security: Handle symlinks by checking their real path to prevent symlink attacks // This prevents attackers from creating symlinks that point outside allowed directories try { const realPath = await fs.realpath(absolute); const normalizedReal = normalizePath(realPath); if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) { throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(', ')}`); } return realPath; } catch (error) { // Security: For new files that don't exist yet, verify parent directory // This ensures we can't create files in unauthorized locations if ((error as NodeJS.ErrnoException).code === 'ENOENT') { try { return await resolveUnicodeEquivalentPath(absolute); } catch (resolutionError) { if ((resolutionError as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`); } throw resolutionError; } } throw error; } } // File Operations export async function getFileStats(filePath: string): Promise { const stats = await fs.stat(filePath); return { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), }; } export async function readFileContent(filePath: string, encoding: string = 'utf-8'): Promise { return await fs.readFile(filePath, encoding as BufferEncoding); } export async function writeFileContent(filePath: string, content: string): Promise { try { // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists, // preventing writes through pre-existing symlinks await fs.writeFile(filePath, content, { encoding: "utf-8", flag: 'wx' }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { // Security: Use atomic rename to prevent race conditions where symlinks // could be created between validation and write. Rename operations // replace the target file atomically and don't follow symlinks. const origStats = await fs.stat(filePath); const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; try { await fs.writeFile(tempPath, content, 'utf-8'); await fs.rename(tempPath, filePath); } catch (renameError) { try { await fs.unlink(tempPath); } catch {} throw renameError; } // Restore original permission bits since the atomic rename replaces the // inode and the temp file has default (0644) permissions. Mask off the // file-type bits; POSIX leaves them unspecified for chmod. A chmod // failure must not fail the write, which has already succeeded. try { await fs.chmod(filePath, origStats.mode & 0o777); } catch {} } else { throw error; } } } export async function moveFile(sourcePath: string, destinationPath: string): Promise { // The move_file tool contract (and README) state the operation fails if the // destination already exists. fs.rename would silently overwrite it, which is // a data-loss bug, so reject up front when anything - file, directory, or // symlink - occupies the target. lstat is used so an existing symlink at the // destination is detected rather than followed. try { await fs.lstat(destinationPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { await fs.rename(sourcePath, destinationPath); return; } throw error; } throw new Error(`Destination already exists: ${destinationPath}`); } // File Editing Functions interface FileEdit { oldText: string; newText: string; } export async function applyFileEdits( filePath: string, edits: FileEdit[], dryRun: boolean = false ): Promise { // Read file content and normalize line endings const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8')); // Apply edits sequentially let modifiedContent = content; for (const edit of edits) { const normalizedOld = normalizeLineEndings(edit.oldText); const normalizedNew = normalizeLineEndings(edit.newText); // If exact match exists, use it if (modifiedContent.includes(normalizedOld)) { modifiedContent = modifiedContent.replace(normalizedOld, () => normalizedNew); continue; } // Otherwise, try line-by-line matching with flexibility for whitespace const oldLines = normalizedOld.split('\n'); const contentLines = modifiedContent.split('\n'); let matchFound = false; for (let i = 0; i <= contentLines.length - oldLines.length; i++) { const potentialMatch = contentLines.slice(i, i + oldLines.length); // Compare lines with normalized whitespace const isMatch = oldLines.every((oldLine, j) => { const contentLine = potentialMatch[j]; return oldLine.trim() === contentLine.trim(); }); if (isMatch) { // Preserve original indentation of first line const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''; const newLines = normalizedNew.split('\n').map((line, j) => { if (j === 0) return originalIndent + line.trimStart(); // For subsequent lines, try to preserve relative indentation const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''; const newIndent = line.match(/^\s*/)?.[0] || ''; if (oldIndent && newIndent) { const relativeIndent = newIndent.length - oldIndent.length; return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart(); } return line; }); contentLines.splice(i, oldLines.length, ...newLines); modifiedContent = contentLines.join('\n'); matchFound = true; break; } } if (!matchFound) { throw new Error(`Could not find exact match for edit:\n${edit.oldText}`); } } // Create unified diff const diff = createUnifiedDiff(content, modifiedContent, filePath); // Format diff with appropriate number of backticks let numBackticks = 3; while (diff.includes('`'.repeat(numBackticks))) { numBackticks++; } const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`; if (!dryRun) { // Security: Use atomic rename to prevent race conditions where symlinks // could be created between validation and write. Rename operations // replace the target file atomically and don't follow symlinks. const origStats = await fs.stat(filePath); const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; try { await fs.writeFile(tempPath, modifiedContent, 'utf-8'); await fs.rename(tempPath, filePath); } catch (error) { try { await fs.unlink(tempPath); } catch {} throw error; } // Restore original permission bits since the atomic rename replaces the // inode and the temp file has default (0644) permissions. Mask off the // file-type bits; POSIX leaves them unspecified for chmod. A chmod // failure must not fail the write, which has already succeeded. try { await fs.chmod(filePath, origStats.mode & 0o777); } catch {} } return formattedDiff; } // Memory-efficient implementation to get the last N lines of a file export async function tailFile(filePath: string, numLines: number): Promise { const CHUNK_SIZE = 1024; // Read 1KB at a time const stats = await fs.stat(filePath); const fileSize = stats.size; if (fileSize === 0) return ''; // Open file for reading const fileHandle = await fs.open(filePath, 'r'); try { const chunks: Buffer[] = []; let position = fileSize; const chunk = Buffer.alloc(CHUNK_SIZE); let newlinesFound = 0; // Read chunks from the end of the file until we have enough lines while (position > 0 && newlinesFound < numLines) { const size = Math.min(CHUNK_SIZE, position); position -= size; const { bytesRead } = await fileHandle.read(chunk, 0, size, position); if (!bytesRead) break; const readData = Buffer.from(chunk.subarray(0, bytesRead)); chunks.unshift(readData); for (const byte of readData) { if (byte === 0x0a) newlinesFound++; } } const text = normalizeLineEndings(Buffer.concat(chunks).toString('utf-8')); return text.split('\n').slice(-numLines).join('\n'); } finally { await fileHandle.close(); } } // New function to get the first N lines of a file export async function headFile(filePath: string, numLines: number): Promise { const fileHandle = await fs.open(filePath, 'r'); try { const lines: string[] = []; let buffer = ''; let bytesRead = 0; const chunk = Buffer.alloc(1024); // 1KB buffer const decoder = new StringDecoder('utf-8'); // Read chunks and count lines until we have enough or reach EOF while (lines.length < numLines) { const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); if (result.bytesRead === 0) break; // End of file bytesRead += result.bytesRead; buffer += decoder.write(chunk.subarray(0, result.bytesRead)); const newLineIndex = buffer.lastIndexOf('\n'); if (newLineIndex !== -1) { const completeLines = buffer.slice(0, newLineIndex).split('\n'); buffer = buffer.slice(newLineIndex + 1); for (const line of completeLines) { lines.push(line); if (lines.length >= numLines) break; } } } buffer += decoder.end(); // If there is leftover content and we still need lines, add it if (buffer.length > 0 && lines.length < numLines) { lines.push(buffer); } return lines.join('\n'); } finally { await fileHandle.close(); } } export async function searchFilesWithValidation( rootPath: string, pattern: string, allowedDirectories: string[], options: SearchOptions = {} ): Promise { const { excludePatterns = [] } = options; const results: string[] = []; async function search(currentPath: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); try { await validatePath(fullPath); const relativePath = path.relative(rootPath, fullPath); const shouldExclude = excludePatterns.some(excludePattern => minimatch(relativePath, excludePattern, { dot: true }) ); if (shouldExclude) continue; // Use glob matching for the search pattern if (minimatch(relativePath, pattern, { dot: true })) { results.push(fullPath); } if (entry.isDirectory()) { await search(fullPath); } } catch { continue; } } } await search(rootPath); return results; }