@@ -12,12 +12,9 @@ import {
1212import fs from "fs/promises" ;
1313import { createReadStream } from "fs" ;
1414import path from "path" ;
15- import os from 'os' ;
16- import { randomBytes } from 'crypto' ;
1715import { z } from "zod" ;
1816import { zodToJsonSchema } from "zod-to-json-schema" ;
1917import { normalizePath , expandHome } from './path-utils.js' ;
20- import { isPathWithinAllowedDirectories } from './path-validation.js' ;
2118import { getValidRootDirectories } from './roots-utils.js' ;
2219import {
2320 // Function imports
@@ -26,11 +23,11 @@ import {
2623 getFileStats ,
2724 readFileContent ,
2825 writeFileContent ,
29- searchFiles ,
26+ searchFilesWithValidation ,
3027 applyFileEdits ,
3128 tailFile ,
3229 headFile ,
33- setAllowedDirectories
30+ setAllowedDirectories ,
3431} from './lib.js' ;
3532
3633// Command line argument parsing
@@ -157,277 +154,6 @@ const server = new Server(
157154 } ,
158155) ;
159156
160- // Tool implementations
161- async function getFileStats ( filePath : string ) : Promise < FileInfo > {
162- const stats = await fs . stat ( filePath ) ;
163- return {
164- size : stats . size ,
165- created : stats . birthtime ,
166- modified : stats . mtime ,
167- accessed : stats . atime ,
168- isDirectory : stats . isDirectory ( ) ,
169- isFile : stats . isFile ( ) ,
170- permissions : stats . mode . toString ( 8 ) . slice ( - 3 ) ,
171- } ;
172- }
173-
174- async function searchFiles (
175- rootPath : string ,
176- pattern : string ,
177- excludePatterns : string [ ] = [ ]
178- ) : Promise < string [ ] > {
179- const results : string [ ] = [ ] ;
180-
181- async function search ( currentPath : string ) {
182- const entries = await fs . readdir ( currentPath , { withFileTypes : true } ) ;
183-
184- for ( const entry of entries ) {
185- const fullPath = path . join ( currentPath , entry . name ) ;
186-
187- try {
188- // Validate each path before processing
189- await validatePath ( fullPath ) ;
190-
191- // Check if path matches any exclude pattern
192- const relativePath = path . relative ( rootPath , fullPath ) ;
193- const shouldExclude = excludePatterns . some ( pattern => {
194- const globPattern = pattern . includes ( '*' ) ? pattern : `**/${ pattern } /**` ;
195- return minimatch ( relativePath , globPattern , { dot : true } ) ;
196- } ) ;
197-
198- if ( shouldExclude ) {
199- continue ;
200- }
201-
202- if ( entry . name . toLowerCase ( ) . includes ( pattern . toLowerCase ( ) ) ) {
203- results . push ( fullPath ) ;
204- }
205-
206- if ( entry . isDirectory ( ) ) {
207- await search ( fullPath ) ;
208- }
209- } catch ( error ) {
210- // Skip invalid paths during search
211- continue ;
212- }
213- }
214- }
215-
216- await search ( rootPath ) ;
217- return results ;
218- }
219-
220- // file editing and diffing utilities
221- function normalizeLineEndings ( text : string ) : string {
222- return text . replace ( / \r \n / g, '\n' ) ;
223- }
224-
225- function createUnifiedDiff ( originalContent : string , newContent : string , filepath : string = 'file' ) : string {
226- // Ensure consistent line endings for diff
227- const normalizedOriginal = normalizeLineEndings ( originalContent ) ;
228- const normalizedNew = normalizeLineEndings ( newContent ) ;
229-
230- return createTwoFilesPatch (
231- filepath ,
232- filepath ,
233- normalizedOriginal ,
234- normalizedNew ,
235- 'original' ,
236- 'modified'
237- ) ;
238- }
239-
240- async function applyFileEdits (
241- filePath : string ,
242- edits : Array < { oldText : string , newText : string } > ,
243- dryRun = false
244- ) : Promise < string > {
245- // Read file content and normalize line endings
246- const content = normalizeLineEndings ( await fs . readFile ( filePath , 'utf-8' ) ) ;
247-
248- // Apply edits sequentially
249- let modifiedContent = content ;
250- for ( const edit of edits ) {
251- const normalizedOld = normalizeLineEndings ( edit . oldText ) ;
252- const normalizedNew = normalizeLineEndings ( edit . newText ) ;
253-
254- // If exact match exists, use it
255- if ( modifiedContent . includes ( normalizedOld ) ) {
256- modifiedContent = modifiedContent . replace ( normalizedOld , normalizedNew ) ;
257- continue ;
258- }
259-
260- // Otherwise, try line-by-line matching with flexibility for whitespace
261- const oldLines = normalizedOld . split ( '\n' ) ;
262- const contentLines = modifiedContent . split ( '\n' ) ;
263- let matchFound = false ;
264-
265- for ( let i = 0 ; i <= contentLines . length - oldLines . length ; i ++ ) {
266- const potentialMatch = contentLines . slice ( i , i + oldLines . length ) ;
267-
268- // Compare lines with normalized whitespace
269- const isMatch = oldLines . every ( ( oldLine , j ) => {
270- const contentLine = potentialMatch [ j ] ;
271- return oldLine . trim ( ) === contentLine . trim ( ) ;
272- } ) ;
273-
274- if ( isMatch ) {
275- // Preserve original indentation of first line
276- const originalIndent = contentLines [ i ] . match ( / ^ \s * / ) ?. [ 0 ] || '' ;
277- const newLines = normalizedNew . split ( '\n' ) . map ( ( line , j ) => {
278- if ( j === 0 ) return originalIndent + line . trimStart ( ) ;
279- // For subsequent lines, try to preserve relative indentation
280- const oldIndent = oldLines [ j ] ?. match ( / ^ \s * / ) ?. [ 0 ] || '' ;
281- const newIndent = line . match ( / ^ \s * / ) ?. [ 0 ] || '' ;
282- if ( oldIndent && newIndent ) {
283- const relativeIndent = newIndent . length - oldIndent . length ;
284- return originalIndent + ' ' . repeat ( Math . max ( 0 , relativeIndent ) ) + line . trimStart ( ) ;
285- }
286- return line ;
287- } ) ;
288-
289- contentLines . splice ( i , oldLines . length , ...newLines ) ;
290- modifiedContent = contentLines . join ( '\n' ) ;
291- matchFound = true ;
292- break ;
293- }
294- }
295-
296- if ( ! matchFound ) {
297- throw new Error ( `Could not find exact match for edit:\n${ edit . oldText } ` ) ;
298- }
299- }
300-
301- // Create unified diff
302- const diff = createUnifiedDiff ( content , modifiedContent , filePath ) ;
303-
304- // Format diff with appropriate number of backticks
305- let numBackticks = 3 ;
306- while ( diff . includes ( '`' . repeat ( numBackticks ) ) ) {
307- numBackticks ++ ;
308- }
309- const formattedDiff = `${ '`' . repeat ( numBackticks ) } diff\n${ diff } ${ '`' . repeat ( numBackticks ) } \n\n` ;
310-
311- if ( ! dryRun ) {
312- // Security: Use atomic rename to prevent race conditions where symlinks
313- // could be created between validation and write. Rename operations
314- // replace the target file atomically and don't follow symlinks.
315- const tempPath = `${ filePath } .${ randomBytes ( 16 ) . toString ( 'hex' ) } .tmp` ;
316- try {
317- await fs . writeFile ( tempPath , modifiedContent , 'utf-8' ) ;
318- await fs . rename ( tempPath , filePath ) ;
319- } catch ( error ) {
320- try {
321- await fs . unlink ( tempPath ) ;
322- } catch { }
323- throw error ;
324- }
325- }
326-
327- return formattedDiff ;
328- }
329-
330- // Helper functions
331- function formatSize ( bytes : number ) : string {
332- const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ] ;
333- if ( bytes === 0 ) return '0 B' ;
334-
335- const i = Math . floor ( Math . log ( bytes ) / Math . log ( 1024 ) ) ;
336- if ( i === 0 ) return `${ bytes } ${ units [ i ] } ` ;
337-
338- return `${ ( bytes / Math . pow ( 1024 , i ) ) . toFixed ( 2 ) } ${ units [ i ] } ` ;
339- }
340-
341- // Memory-efficient implementation to get the last N lines of a file
342- async function tailFile ( filePath : string , numLines : number ) : Promise < string > {
343- const CHUNK_SIZE = 1024 ; // Read 1KB at a time
344- const stats = await fs . stat ( filePath ) ;
345- const fileSize = stats . size ;
346-
347- if ( fileSize === 0 ) return '' ;
348-
349- // Open file for reading
350- const fileHandle = await fs . open ( filePath , 'r' ) ;
351- try {
352- const lines : string [ ] = [ ] ;
353- let position = fileSize ;
354- let chunk = Buffer . alloc ( CHUNK_SIZE ) ;
355- let linesFound = 0 ;
356- let remainingText = '' ;
357-
358- // Read chunks from the end of the file until we have enough lines
359- while ( position > 0 && linesFound < numLines ) {
360- const size = Math . min ( CHUNK_SIZE , position ) ;
361- position -= size ;
362-
363- const { bytesRead } = await fileHandle . read ( chunk , 0 , size , position ) ;
364- if ( ! bytesRead ) break ;
365-
366- // Get the chunk as a string and prepend any remaining text from previous iteration
367- const readData = chunk . slice ( 0 , bytesRead ) . toString ( 'utf-8' ) ;
368- const chunkText = readData + remainingText ;
369-
370- // Split by newlines and count
371- const chunkLines = normalizeLineEndings ( chunkText ) . split ( '\n' ) ;
372-
373- // If this isn't the end of the file, the first line is likely incomplete
374- // Save it to prepend to the next chunk
375- if ( position > 0 ) {
376- remainingText = chunkLines [ 0 ] ;
377- chunkLines . shift ( ) ; // Remove the first (incomplete) line
378- }
379-
380- // Add lines to our result (up to the number we need)
381- for ( let i = chunkLines . length - 1 ; i >= 0 && linesFound < numLines ; i -- ) {
382- lines . unshift ( chunkLines [ i ] ) ;
383- linesFound ++ ;
384- }
385- }
386-
387- return lines . join ( '\n' ) ;
388- } finally {
389- await fileHandle . close ( ) ;
390- }
391- }
392-
393- // New function to get the first N lines of a file
394- async function headFile ( filePath : string , numLines : number ) : Promise < string > {
395- const fileHandle = await fs . open ( filePath , 'r' ) ;
396- try {
397- const lines : string [ ] = [ ] ;
398- let buffer = '' ;
399- let bytesRead = 0 ;
400- const chunk = Buffer . alloc ( 1024 ) ; // 1KB buffer
401-
402- // Read chunks and count lines until we have enough or reach EOF
403- while ( lines . length < numLines ) {
404- const result = await fileHandle . read ( chunk , 0 , chunk . length , bytesRead ) ;
405- if ( result . bytesRead === 0 ) break ; // End of file
406- bytesRead += result . bytesRead ;
407- buffer += chunk . slice ( 0 , result . bytesRead ) . toString ( 'utf-8' ) ;
408-
409- const newLineIndex = buffer . lastIndexOf ( '\n' ) ;
410- if ( newLineIndex !== - 1 ) {
411- const completeLines = buffer . slice ( 0 , newLineIndex ) . split ( '\n' ) ;
412- buffer = buffer . slice ( newLineIndex + 1 ) ;
413- for ( const line of completeLines ) {
414- lines . push ( line ) ;
415- if ( lines . length >= numLines ) break ;
416- }
417- }
418- }
419-
420- // If there is leftover content and we still need lines, add it
421- if ( buffer . length > 0 && lines . length < numLines ) {
422- lines . push ( buffer ) ;
423- }
424-
425- return lines . join ( '\n' ) ;
426- } finally {
427- await fileHandle . close ( ) ;
428- }
429- }
430-
431157// Reads a file as a stream of buffers, concatenates them, and then encodes
432158// the result to a Base64 string. This is a memory-efficient way to handle
433159// binary data from a stream before the final encoding.
@@ -851,7 +577,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
851577 throw new Error ( `Invalid arguments for search_files: ${ parsed . error } ` ) ;
852578 }
853579 const validPath = await validatePath ( parsed . data . path ) ;
854- const results = await searchFiles ( validPath , parsed . data . pattern , parsed . data . excludePatterns ) ;
580+ const results = await searchFilesWithValidation ( validPath , parsed . data . pattern , allowedDirectories , { excludePatterns : parsed . data . excludePatterns } ) ;
855581 return {
856582 content : [ { type : "text" , text : results . length > 0 ? results . join ( "\n" ) : "No matches found" } ] ,
857583 } ;
0 commit comments