/ tools / FileEditTool / FileEditTool.ts
FileEditTool.ts
  1  import { dirname, isAbsolute, sep } from 'path'
  2  import { logEvent } from 'src/services/analytics/index.js'
  3  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  4  import { diagnosticTracker } from '../../services/diagnosticTracking.js'
  5  import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js'
  6  import { getLspServerManager } from '../../services/lsp/manager.js'
  7  import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js'
  8  import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js'
  9  import {
 10    activateConditionalSkillsForPaths,
 11    addSkillDirectories,
 12    discoverSkillDirsForPaths,
 13  } from '../../skills/loadSkillsDir.js'
 14  import type { ToolUseContext } from '../../Tool.js'
 15  import { buildTool, type ToolDef } from '../../Tool.js'
 16  import { getCwd } from '../../utils/cwd.js'
 17  import { logForDebugging } from '../../utils/debug.js'
 18  import { countLinesChanged } from '../../utils/diff.js'
 19  import { isEnvTruthy } from '../../utils/envUtils.js'
 20  import { isENOENT } from '../../utils/errors.js'
 21  import {
 22    FILE_NOT_FOUND_CWD_NOTE,
 23    findSimilarFile,
 24    getFileModificationTime,
 25    suggestPathUnderCwd,
 26    writeTextContent,
 27  } from '../../utils/file.js'
 28  import {
 29    fileHistoryEnabled,
 30    fileHistoryTrackEdit,
 31  } from '../../utils/fileHistory.js'
 32  import { logFileOperation } from '../../utils/fileOperationAnalytics.js'
 33  import {
 34    type LineEndingType,
 35    readFileSyncWithMetadata,
 36  } from '../../utils/fileRead.js'
 37  import { formatFileSize } from '../../utils/format.js'
 38  import { getFsImplementation } from '../../utils/fsOperations.js'
 39  import {
 40    fetchSingleFileGitDiff,
 41    type ToolUseDiff,
 42  } from '../../utils/gitDiff.js'
 43  import { logError } from '../../utils/log.js'
 44  import { expandPath } from '../../utils/path.js'
 45  import {
 46    checkWritePermissionForTool,
 47    matchingRuleForInput,
 48  } from '../../utils/permissions/filesystem.js'
 49  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
 50  import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
 51  import { validateInputForSettingsFileEdit } from '../../utils/settings/validateEditTool.js'
 52  import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js'
 53  import {
 54    FILE_EDIT_TOOL_NAME,
 55    FILE_UNEXPECTEDLY_MODIFIED_ERROR,
 56  } from './constants.js'
 57  import { getEditToolDescription } from './prompt.js'
 58  import {
 59    type FileEditInput,
 60    type FileEditOutput,
 61    inputSchema,
 62    outputSchema,
 63  } from './types.js'
 64  import {
 65    getToolUseSummary,
 66    renderToolResultMessage,
 67    renderToolUseErrorMessage,
 68    renderToolUseMessage,
 69    renderToolUseRejectedMessage,
 70    userFacingName,
 71  } from './UI.js'
 72  import {
 73    areFileEditsInputsEquivalent,
 74    findActualString,
 75    getPatchForEdit,
 76    preserveQuoteStyle,
 77  } from './utils.js'
 78  
 79  // V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
 80  // ASCII/Latin-1 files, 1 byte on disk = 1 character, so 1 GiB in stat bytes
 81  // ≈ 1 billion characters ≈ the runtime string limit. Multi-byte UTF-8 files
 82  // can be larger on disk per character, but 1 GiB is a safe byte-level guard
 83  // that prevents OOM without being unnecessarily restrictive.
 84  const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat bytes)
 85  
 86  export const FileEditTool = buildTool({
 87    name: FILE_EDIT_TOOL_NAME,
 88    searchHint: 'modify file contents in place',
 89    maxResultSizeChars: 100_000,
 90    strict: true,
 91    async description() {
 92      return 'A tool for editing files'
 93    },
 94    async prompt() {
 95      return getEditToolDescription()
 96    },
 97    userFacingName,
 98    getToolUseSummary,
 99    getActivityDescription(input) {
100      const summary = getToolUseSummary(input)
101      return summary ? `Editing ${summary}` : 'Editing file'
102    },
103    get inputSchema() {
104      return inputSchema()
105    },
106    get outputSchema() {
107      return outputSchema()
108    },
109    toAutoClassifierInput(input) {
110      return `${input.file_path}: ${input.new_string}`
111    },
112    getPath(input): string {
113      return input.file_path
114    },
115    backfillObservableInput(input) {
116      // hooks.mdx documents file_path as absolute; expand so hook allowlists
117      // can't be bypassed via ~ or relative paths.
118      if (typeof input.file_path === 'string') {
119        input.file_path = expandPath(input.file_path)
120      }
121    },
122    async preparePermissionMatcher({ file_path }) {
123      return pattern => matchWildcardPattern(pattern, file_path)
124    },
125    async checkPermissions(input, context): Promise<PermissionDecision> {
126      const appState = context.getAppState()
127      return checkWritePermissionForTool(
128        FileEditTool,
129        input,
130        appState.toolPermissionContext,
131      )
132    },
133    renderToolUseMessage,
134    renderToolResultMessage,
135    renderToolUseRejectedMessage,
136    renderToolUseErrorMessage,
137    async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) {
138      const { file_path, old_string, new_string, replace_all = false } = input
139      // Use expandPath for consistent path normalization (especially on Windows
140      // where "/" vs "\" can cause readFileState lookup mismatches)
141      const fullFilePath = expandPath(file_path)
142  
143      // Reject edits to team memory files that introduce secrets
144      const secretError = checkTeamMemSecrets(fullFilePath, new_string)
145      if (secretError) {
146        return { result: false, message: secretError, errorCode: 0 }
147      }
148      if (old_string === new_string) {
149        return {
150          result: false,
151          behavior: 'ask',
152          message:
153            'No changes to make: old_string and new_string are exactly the same.',
154          errorCode: 1,
155        }
156      }
157  
158      // Check if path should be ignored based on permission settings
159      const appState = toolUseContext.getAppState()
160      const denyRule = matchingRuleForInput(
161        fullFilePath,
162        appState.toolPermissionContext,
163        'edit',
164        'deny',
165      )
166      if (denyRule !== null) {
167        return {
168          result: false,
169          behavior: 'ask',
170          message:
171            'File is in a directory that is denied by your permission settings.',
172          errorCode: 2,
173        }
174      }
175  
176      // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
177      // On Windows, fs.existsSync() on UNC paths triggers SMB authentication which could
178      // leak credentials to malicious servers. Let the permission check handle UNC paths.
179      if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
180        return { result: true }
181      }
182  
183      const fs = getFsImplementation()
184  
185      // Prevent OOM on multi-GB files.
186      try {
187        const { size } = await fs.stat(fullFilePath)
188        if (size > MAX_EDIT_FILE_SIZE) {
189          return {
190            result: false,
191            behavior: 'ask',
192            message: `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
193            errorCode: 10,
194          }
195        }
196      } catch (e) {
197        if (!isENOENT(e)) {
198          throw e
199        }
200      }
201  
202      // Read the file as bytes first so we can detect encoding from the buffer
203      // instead of calling detectFileEncoding (which does its own sync readSync
204      // and would fail with a wasted ENOENT when the file doesn't exist).
205      let fileContent: string | null
206      try {
207        const fileBuffer = await fs.readFileBytes(fullFilePath)
208        const encoding: BufferEncoding =
209          fileBuffer.length >= 2 &&
210          fileBuffer[0] === 0xff &&
211          fileBuffer[1] === 0xfe
212            ? 'utf16le'
213            : 'utf8'
214        fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
215      } catch (e) {
216        if (isENOENT(e)) {
217          fileContent = null
218        } else {
219          throw e
220        }
221      }
222  
223      // File doesn't exist
224      if (fileContent === null) {
225        // Empty old_string on nonexistent file means new file creation — valid
226        if (old_string === '') {
227          return { result: true }
228        }
229        // Try to find a similar file with a different extension
230        const similarFilename = findSimilarFile(fullFilePath)
231        const cwdSuggestion = await suggestPathUnderCwd(fullFilePath)
232        let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
233  
234        if (cwdSuggestion) {
235          message += ` Did you mean ${cwdSuggestion}?`
236        } else if (similarFilename) {
237          message += ` Did you mean ${similarFilename}?`
238        }
239  
240        return {
241          result: false,
242          behavior: 'ask',
243          message,
244          errorCode: 4,
245        }
246      }
247  
248      // File exists with empty old_string — only valid if file is empty
249      if (old_string === '') {
250        // Only reject if the file has content (for file creation attempt)
251        if (fileContent.trim() !== '') {
252          return {
253            result: false,
254            behavior: 'ask',
255            message: 'Cannot create new file - file already exists.',
256            errorCode: 3,
257          }
258        }
259  
260        // Empty file with empty old_string is valid - we're replacing empty with content
261        return {
262          result: true,
263        }
264      }
265  
266      if (fullFilePath.endsWith('.ipynb')) {
267        return {
268          result: false,
269          behavior: 'ask',
270          message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`,
271          errorCode: 5,
272        }
273      }
274  
275      const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
276      if (!readTimestamp || readTimestamp.isPartialView) {
277        return {
278          result: false,
279          behavior: 'ask',
280          message:
281            'File has not been read yet. Read it first before writing to it.',
282          meta: {
283            isFilePathAbsolute: String(isAbsolute(file_path)),
284          },
285          errorCode: 6,
286        }
287      }
288  
289      // Check if file exists and get its last modified time
290      if (readTimestamp) {
291        const lastWriteTime = getFileModificationTime(fullFilePath)
292        if (lastWriteTime > readTimestamp.timestamp) {
293          // Timestamp indicates modification, but on Windows timestamps can change
294          // without content changes (cloud sync, antivirus, etc.). For full reads,
295          // compare content as a fallback to avoid false positives.
296          const isFullRead =
297            readTimestamp.offset === undefined &&
298            readTimestamp.limit === undefined
299          if (isFullRead && fileContent === readTimestamp.content) {
300            // Content unchanged, safe to proceed
301          } else {
302            return {
303              result: false,
304              behavior: 'ask',
305              message:
306                'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
307              errorCode: 7,
308            }
309          }
310        }
311      }
312  
313      const file = fileContent
314  
315      // Use findActualString to handle quote normalization
316      const actualOldString = findActualString(file, old_string)
317      if (!actualOldString) {
318        return {
319          result: false,
320          behavior: 'ask',
321          message: `String to replace not found in file.\nString: ${old_string}`,
322          meta: {
323            isFilePathAbsolute: String(isAbsolute(file_path)),
324          },
325          errorCode: 8,
326        }
327      }
328  
329      const matches = file.split(actualOldString).length - 1
330  
331      // Check if we have multiple matches but replace_all is false
332      if (matches > 1 && !replace_all) {
333        return {
334          result: false,
335          behavior: 'ask',
336          message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
337          meta: {
338            isFilePathAbsolute: String(isAbsolute(file_path)),
339            actualOldString,
340          },
341          errorCode: 9,
342        }
343      }
344  
345      // Additional validation for Claude settings files
346      const settingsValidationResult = validateInputForSettingsFileEdit(
347        fullFilePath,
348        file,
349        () => {
350          // Simulate the edit to get the final content using the exact same logic as the tool
351          return replace_all
352            ? file.replaceAll(actualOldString, new_string)
353            : file.replace(actualOldString, new_string)
354        },
355      )
356  
357      if (settingsValidationResult !== null) {
358        return settingsValidationResult
359      }
360  
361      return { result: true, meta: { actualOldString } }
362    },
363    inputsEquivalent(input1, input2) {
364      return areFileEditsInputsEquivalent(
365        {
366          file_path: input1.file_path,
367          edits: [
368            {
369              old_string: input1.old_string,
370              new_string: input1.new_string,
371              replace_all: input1.replace_all ?? false,
372            },
373          ],
374        },
375        {
376          file_path: input2.file_path,
377          edits: [
378            {
379              old_string: input2.old_string,
380              new_string: input2.new_string,
381              replace_all: input2.replace_all ?? false,
382            },
383          ],
384        },
385      )
386    },
387    async call(
388      input: FileEditInput,
389      {
390        readFileState,
391        userModified,
392        updateFileHistoryState,
393        dynamicSkillDirTriggers,
394      },
395      _,
396      parentMessage,
397    ) {
398      const { file_path, old_string, new_string, replace_all = false } = input
399  
400      // 1. Get current state
401      const fs = getFsImplementation()
402      const absoluteFilePath = expandPath(file_path)
403  
404      // Discover skills from this file's path (fire-and-forget, non-blocking)
405      // Skip in simple mode - no skills available
406      const cwd = getCwd()
407      if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
408        const newSkillDirs = await discoverSkillDirsForPaths(
409          [absoluteFilePath],
410          cwd,
411        )
412        if (newSkillDirs.length > 0) {
413          // Store discovered dirs for attachment display
414          for (const dir of newSkillDirs) {
415            dynamicSkillDirTriggers?.add(dir)
416          }
417          // Don't await - let skill loading happen in the background
418          addSkillDirectories(newSkillDirs).catch(() => {})
419        }
420  
421        // Activate conditional skills whose path patterns match this file
422        activateConditionalSkillsForPaths([absoluteFilePath], cwd)
423      }
424  
425      await diagnosticTracker.beforeFileEdited(absoluteFilePath)
426  
427      // Ensure parent directory exists before the atomic read-modify-write section.
428      // These awaits must stay OUTSIDE the critical section below — a yield between
429      // the staleness check and writeTextContent lets concurrent edits interleave.
430      await fs.mkdir(dirname(absoluteFilePath))
431      if (fileHistoryEnabled()) {
432        // Backup captures pre-edit content — safe to call before the staleness
433        // check (idempotent v1 backup keyed on content hash; if staleness fails
434        // later we just have an unused backup, not corrupt state).
435        await fileHistoryTrackEdit(
436          updateFileHistoryState,
437          absoluteFilePath,
438          parentMessage.uuid,
439        )
440      }
441  
442      // 2. Load current state and confirm no changes since last read
443      // Please avoid async operations between here and writing to disk to preserve atomicity
444      const {
445        content: originalFileContents,
446        fileExists,
447        encoding,
448        lineEndings: endings,
449      } = readFileForEdit(absoluteFilePath)
450  
451      if (fileExists) {
452        const lastWriteTime = getFileModificationTime(absoluteFilePath)
453        const lastRead = readFileState.get(absoluteFilePath)
454        if (!lastRead || lastWriteTime > lastRead.timestamp) {
455          // Timestamp indicates modification, but on Windows timestamps can change
456          // without content changes (cloud sync, antivirus, etc.). For full reads,
457          // compare content as a fallback to avoid false positives.
458          const isFullRead =
459            lastRead &&
460            lastRead.offset === undefined &&
461            lastRead.limit === undefined
462          const contentUnchanged =
463            isFullRead && originalFileContents === lastRead.content
464          if (!contentUnchanged) {
465            throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR)
466          }
467        }
468      }
469  
470      // 3. Use findActualString to handle quote normalization
471      const actualOldString =
472        findActualString(originalFileContents, old_string) || old_string
473  
474      // Preserve curly quotes in new_string when the file uses them
475      const actualNewString = preserveQuoteStyle(
476        old_string,
477        actualOldString,
478        new_string,
479      )
480  
481      // 4. Generate patch
482      const { patch, updatedFile } = getPatchForEdit({
483        filePath: absoluteFilePath,
484        fileContents: originalFileContents,
485        oldString: actualOldString,
486        newString: actualNewString,
487        replaceAll: replace_all,
488      })
489  
490      // 5. Write to disk
491      writeTextContent(absoluteFilePath, updatedFile, encoding, endings)
492  
493      // Notify LSP servers about file modification (didChange) and save (didSave)
494      const lspManager = getLspServerManager()
495      if (lspManager) {
496        // Clear previously delivered diagnostics so new ones will be shown
497        clearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`)
498        // didChange: Content has been modified
499        lspManager
500          .changeFile(absoluteFilePath, updatedFile)
501          .catch((err: Error) => {
502            logForDebugging(
503              `LSP: Failed to notify server of file change for ${absoluteFilePath}: ${err.message}`,
504            )
505            logError(err)
506          })
507        // didSave: File has been saved to disk (triggers diagnostics in TypeScript server)
508        lspManager.saveFile(absoluteFilePath).catch((err: Error) => {
509          logForDebugging(
510            `LSP: Failed to notify server of file save for ${absoluteFilePath}: ${err.message}`,
511          )
512          logError(err)
513        })
514      }
515  
516      // Notify VSCode about the file change for diff view
517      notifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile)
518  
519      // 6. Update read timestamp, to invalidate stale writes
520      readFileState.set(absoluteFilePath, {
521        content: updatedFile,
522        timestamp: getFileModificationTime(absoluteFilePath),
523        offset: undefined,
524        limit: undefined,
525      })
526  
527      // 7. Log events
528      if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) {
529        logEvent('tengu_write_claudemd', {})
530      }
531      countLinesChanged(patch)
532  
533      logFileOperation({
534        operation: 'edit',
535        tool: 'FileEditTool',
536        filePath: absoluteFilePath,
537      })
538  
539      logEvent('tengu_edit_string_lengths', {
540        oldStringBytes: Buffer.byteLength(old_string, 'utf8'),
541        newStringBytes: Buffer.byteLength(new_string, 'utf8'),
542        replaceAll: replace_all,
543      })
544  
545      let gitDiff: ToolUseDiff | undefined
546      if (
547        isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
548        getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false)
549      ) {
550        const startTime = Date.now()
551        const diff = await fetchSingleFileGitDiff(absoluteFilePath)
552        if (diff) gitDiff = diff
553        logEvent('tengu_tool_use_diff_computed', {
554          isEditTool: true,
555          durationMs: Date.now() - startTime,
556          hasDiff: !!diff,
557        })
558      }
559  
560      // 8. Yield result
561      const data = {
562        filePath: file_path,
563        oldString: actualOldString,
564        newString: new_string,
565        originalFile: originalFileContents,
566        structuredPatch: patch,
567        userModified: userModified ?? false,
568        replaceAll: replace_all,
569        ...(gitDiff && { gitDiff }),
570      }
571      return {
572        data,
573      }
574    },
575    mapToolResultToToolResultBlockParam(data: FileEditOutput, toolUseID) {
576      const { filePath, userModified, replaceAll } = data
577      const modifiedNote = userModified
578        ? '.  The user modified your proposed changes before accepting them. '
579        : ''
580  
581      if (replaceAll) {
582        return {
583          tool_use_id: toolUseID,
584          type: 'tool_result',
585          content: `The file ${filePath} has been updated${modifiedNote}. All occurrences were successfully replaced.`,
586        }
587      }
588  
589      return {
590        tool_use_id: toolUseID,
591        type: 'tool_result',
592        content: `The file ${filePath} has been updated successfully${modifiedNote}.`,
593      }
594    },
595  } satisfies ToolDef<ReturnType<typeof inputSchema>, FileEditOutput>)
596  
597  // --
598  
599  function readFileForEdit(absoluteFilePath: string): {
600    content: string
601    fileExists: boolean
602    encoding: BufferEncoding
603    lineEndings: LineEndingType
604  } {
605    try {
606      // eslint-disable-next-line custom-rules/no-sync-fs
607      const meta = readFileSyncWithMetadata(absoluteFilePath)
608      return {
609        content: meta.content,
610        fileExists: true,
611        encoding: meta.encoding,
612        lineEndings: meta.lineEndings,
613      }
614    } catch (e) {
615      if (isENOENT(e)) {
616        return {
617          content: '',
618          fileExists: false,
619          encoding: 'utf8',
620          lineEndings: 'LF',
621        }
622      }
623      throw e
624    }
625  }