import { dirname, isAbsolute, sep } from 'path' import { logEvent } from 'src/services/analytics/index.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' import { diagnosticTracker } from '../../services/diagnosticTracking.js' import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js' import { getLspServerManager } from '../../services/lsp/manager.js' import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js' import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js' import { activateConditionalSkillsForPaths, addSkillDirectories, discoverSkillDirsForPaths, } from '../../skills/loadSkillsDir.js' import type { ToolUseContext } from '../../Tool.js' import { buildTool, type ToolDef } from '../../Tool.js' import { getCwd } from '../../utils/cwd.js' import { logForDebugging } from '../../utils/debug.js' import { countLinesChanged } from '../../utils/diff.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { isENOENT } from '../../utils/errors.js' import { FILE_NOT_FOUND_CWD_NOTE, findSimilarFile, getFileModificationTime, suggestPathUnderCwd, writeTextContent, } from '../../utils/file.js' import { fileHistoryEnabled, fileHistoryTrackEdit, } from '../../utils/fileHistory.js' import { logFileOperation } from '../../utils/fileOperationAnalytics.js' import { type LineEndingType, readFileSyncWithMetadata, } from '../../utils/fileRead.js' import { formatFileSize } from '../../utils/format.js' import { getFsImplementation } from '../../utils/fsOperations.js' import { fetchSingleFileGitDiff, type ToolUseDiff, } from '../../utils/gitDiff.js' import { logError } from '../../utils/log.js' import { expandPath } from '../../utils/path.js' import { checkWritePermissionForTool, matchingRuleForInput, } from '../../utils/permissions/filesystem.js' import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' import { validateInputForSettingsFileEdit } from '../../utils/settings/validateEditTool.js' import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js' import { FILE_EDIT_TOOL_NAME, FILE_UNEXPECTEDLY_MODIFIED_ERROR, } from './constants.js' import { getEditToolDescription } from './prompt.js' import { type FileEditInput, type FileEditOutput, inputSchema, outputSchema, } from './types.js' import { getToolUseSummary, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseRejectedMessage, userFacingName, } from './UI.js' import { areFileEditsInputsEquivalent, findActualString, getPatchForEdit, preserveQuoteStyle, } from './utils.js' // V8/Bun string length limit is ~2^30 characters (~1 billion). For typical // ASCII/Latin-1 files, 1 byte on disk = 1 character, so 1 GiB in stat bytes // ≈ 1 billion characters ≈ the runtime string limit. Multi-byte UTF-8 files // can be larger on disk per character, but 1 GiB is a safe byte-level guard // that prevents OOM without being unnecessarily restrictive. const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat bytes) export const FileEditTool = buildTool({ name: FILE_EDIT_TOOL_NAME, searchHint: 'modify file contents in place', maxResultSizeChars: 100_000, strict: true, async description() { return 'A tool for editing files' }, async prompt() { return getEditToolDescription() }, userFacingName, getToolUseSummary, getActivityDescription(input) { const summary = getToolUseSummary(input) return summary ? `Editing ${summary}` : 'Editing file' }, get inputSchema() { return inputSchema() }, get outputSchema() { return outputSchema() }, toAutoClassifierInput(input) { return `${input.file_path}: ${input.new_string}` }, getPath(input): string { return input.file_path }, backfillObservableInput(input) { // hooks.mdx documents file_path as absolute; expand so hook allowlists // can't be bypassed via ~ or relative paths. if (typeof input.file_path === 'string') { input.file_path = expandPath(input.file_path) } }, async preparePermissionMatcher({ file_path }) { return pattern => matchWildcardPattern(pattern, file_path) }, async checkPermissions(input, context): Promise { const appState = context.getAppState() return checkWritePermissionForTool( FileEditTool, input, appState.toolPermissionContext, ) }, renderToolUseMessage, renderToolResultMessage, renderToolUseRejectedMessage, renderToolUseErrorMessage, async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) { const { file_path, old_string, new_string, replace_all = false } = input // Use expandPath for consistent path normalization (especially on Windows // where "/" vs "\" can cause readFileState lookup mismatches) const fullFilePath = expandPath(file_path) // Reject edits to team memory files that introduce secrets const secretError = checkTeamMemSecrets(fullFilePath, new_string) if (secretError) { return { result: false, message: secretError, errorCode: 0 } } if (old_string === new_string) { return { result: false, behavior: 'ask', message: 'No changes to make: old_string and new_string are exactly the same.', errorCode: 1, } } // Check if path should be ignored based on permission settings const appState = toolUseContext.getAppState() const denyRule = matchingRuleForInput( fullFilePath, appState.toolPermissionContext, 'edit', 'deny', ) if (denyRule !== null) { return { result: false, behavior: 'ask', message: 'File is in a directory that is denied by your permission settings.', errorCode: 2, } } // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks. // On Windows, fs.existsSync() on UNC paths triggers SMB authentication which could // leak credentials to malicious servers. Let the permission check handle UNC paths. if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) { return { result: true } } const fs = getFsImplementation() // Prevent OOM on multi-GB files. try { const { size } = await fs.stat(fullFilePath) if (size > MAX_EDIT_FILE_SIZE) { return { result: false, behavior: 'ask', message: `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`, errorCode: 10, } } } catch (e) { if (!isENOENT(e)) { throw e } } // Read the file as bytes first so we can detect encoding from the buffer // instead of calling detectFileEncoding (which does its own sync readSync // and would fail with a wasted ENOENT when the file doesn't exist). let fileContent: string | null try { const fileBuffer = await fs.readFileBytes(fullFilePath) const encoding: BufferEncoding = fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe ? 'utf16le' : 'utf8' fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n') } catch (e) { if (isENOENT(e)) { fileContent = null } else { throw e } } // File doesn't exist if (fileContent === null) { // Empty old_string on nonexistent file means new file creation — valid if (old_string === '') { return { result: true } } // Try to find a similar file with a different extension const similarFilename = findSimilarFile(fullFilePath) const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.` if (cwdSuggestion) { message += ` Did you mean ${cwdSuggestion}?` } else if (similarFilename) { message += ` Did you mean ${similarFilename}?` } return { result: false, behavior: 'ask', message, errorCode: 4, } } // File exists with empty old_string — only valid if file is empty if (old_string === '') { // Only reject if the file has content (for file creation attempt) if (fileContent.trim() !== '') { return { result: false, behavior: 'ask', message: 'Cannot create new file - file already exists.', errorCode: 3, } } // Empty file with empty old_string is valid - we're replacing empty with content return { result: true, } } if (fullFilePath.endsWith('.ipynb')) { return { result: false, behavior: 'ask', message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`, errorCode: 5, } } const readTimestamp = toolUseContext.readFileState.get(fullFilePath) if (!readTimestamp || readTimestamp.isPartialView) { return { result: false, behavior: 'ask', message: 'File has not been read yet. Read it first before writing to it.', meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, errorCode: 6, } } // Check if file exists and get its last modified time if (readTimestamp) { const lastWriteTime = getFileModificationTime(fullFilePath) if (lastWriteTime > readTimestamp.timestamp) { // Timestamp indicates modification, but on Windows timestamps can change // without content changes (cloud sync, antivirus, etc.). For full reads, // compare content as a fallback to avoid false positives. const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined if (isFullRead && fileContent === readTimestamp.content) { // Content unchanged, safe to proceed } else { return { result: false, behavior: 'ask', message: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', errorCode: 7, } } } } const file = fileContent // Use findActualString to handle quote normalization const actualOldString = findActualString(file, old_string) if (!actualOldString) { return { result: false, behavior: 'ask', message: `String to replace not found in file.\nString: ${old_string}`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, errorCode: 8, } } const matches = file.split(actualOldString).length - 1 // Check if we have multiple matches but replace_all is false if (matches > 1 && !replace_all) { return { result: false, behavior: 'ask', 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}`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), actualOldString, }, errorCode: 9, } } // Additional validation for Claude settings files const settingsValidationResult = validateInputForSettingsFileEdit( fullFilePath, file, () => { // Simulate the edit to get the final content using the exact same logic as the tool return replace_all ? file.replaceAll(actualOldString, new_string) : file.replace(actualOldString, new_string) }, ) if (settingsValidationResult !== null) { return settingsValidationResult } return { result: true, meta: { actualOldString } } }, inputsEquivalent(input1, input2) { return areFileEditsInputsEquivalent( { file_path: input1.file_path, edits: [ { old_string: input1.old_string, new_string: input1.new_string, replace_all: input1.replace_all ?? false, }, ], }, { file_path: input2.file_path, edits: [ { old_string: input2.old_string, new_string: input2.new_string, replace_all: input2.replace_all ?? false, }, ], }, ) }, async call( input: FileEditInput, { readFileState, userModified, updateFileHistoryState, dynamicSkillDirTriggers, }, _, parentMessage, ) { const { file_path, old_string, new_string, replace_all = false } = input // 1. Get current state const fs = getFsImplementation() const absoluteFilePath = expandPath(file_path) // Discover skills from this file's path (fire-and-forget, non-blocking) // Skip in simple mode - no skills available const cwd = getCwd() if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { const newSkillDirs = await discoverSkillDirsForPaths( [absoluteFilePath], cwd, ) if (newSkillDirs.length > 0) { // Store discovered dirs for attachment display for (const dir of newSkillDirs) { dynamicSkillDirTriggers?.add(dir) } // Don't await - let skill loading happen in the background addSkillDirectories(newSkillDirs).catch(() => {}) } // Activate conditional skills whose path patterns match this file activateConditionalSkillsForPaths([absoluteFilePath], cwd) } await diagnosticTracker.beforeFileEdited(absoluteFilePath) // Ensure parent directory exists before the atomic read-modify-write section. // These awaits must stay OUTSIDE the critical section below — a yield between // the staleness check and writeTextContent lets concurrent edits interleave. await fs.mkdir(dirname(absoluteFilePath)) if (fileHistoryEnabled()) { // Backup captures pre-edit content — safe to call before the staleness // check (idempotent v1 backup keyed on content hash; if staleness fails // later we just have an unused backup, not corrupt state). await fileHistoryTrackEdit( updateFileHistoryState, absoluteFilePath, parentMessage.uuid, ) } // 2. Load current state and confirm no changes since last read // Please avoid async operations between here and writing to disk to preserve atomicity const { content: originalFileContents, fileExists, encoding, lineEndings: endings, } = readFileForEdit(absoluteFilePath) if (fileExists) { const lastWriteTime = getFileModificationTime(absoluteFilePath) const lastRead = readFileState.get(absoluteFilePath) if (!lastRead || lastWriteTime > lastRead.timestamp) { // Timestamp indicates modification, but on Windows timestamps can change // without content changes (cloud sync, antivirus, etc.). For full reads, // compare content as a fallback to avoid false positives. const isFullRead = lastRead && lastRead.offset === undefined && lastRead.limit === undefined const contentUnchanged = isFullRead && originalFileContents === lastRead.content if (!contentUnchanged) { throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR) } } } // 3. Use findActualString to handle quote normalization const actualOldString = findActualString(originalFileContents, old_string) || old_string // Preserve curly quotes in new_string when the file uses them const actualNewString = preserveQuoteStyle( old_string, actualOldString, new_string, ) // 4. Generate patch const { patch, updatedFile } = getPatchForEdit({ filePath: absoluteFilePath, fileContents: originalFileContents, oldString: actualOldString, newString: actualNewString, replaceAll: replace_all, }) // 5. Write to disk writeTextContent(absoluteFilePath, updatedFile, encoding, endings) // Notify LSP servers about file modification (didChange) and save (didSave) const lspManager = getLspServerManager() if (lspManager) { // Clear previously delivered diagnostics so new ones will be shown clearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`) // didChange: Content has been modified lspManager .changeFile(absoluteFilePath, updatedFile) .catch((err: Error) => { logForDebugging( `LSP: Failed to notify server of file change for ${absoluteFilePath}: ${err.message}`, ) logError(err) }) // didSave: File has been saved to disk (triggers diagnostics in TypeScript server) lspManager.saveFile(absoluteFilePath).catch((err: Error) => { logForDebugging( `LSP: Failed to notify server of file save for ${absoluteFilePath}: ${err.message}`, ) logError(err) }) } // Notify VSCode about the file change for diff view notifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile) // 6. Update read timestamp, to invalidate stale writes readFileState.set(absoluteFilePath, { content: updatedFile, timestamp: getFileModificationTime(absoluteFilePath), offset: undefined, limit: undefined, }) // 7. Log events if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) { logEvent('tengu_write_claudemd', {}) } countLinesChanged(patch) logFileOperation({ operation: 'edit', tool: 'FileEditTool', filePath: absoluteFilePath, }) logEvent('tengu_edit_string_lengths', { oldStringBytes: Buffer.byteLength(old_string, 'utf8'), newStringBytes: Buffer.byteLength(new_string, 'utf8'), replaceAll: replace_all, }) let gitDiff: ToolUseDiff | undefined if ( isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false) ) { const startTime = Date.now() const diff = await fetchSingleFileGitDiff(absoluteFilePath) if (diff) gitDiff = diff logEvent('tengu_tool_use_diff_computed', { isEditTool: true, durationMs: Date.now() - startTime, hasDiff: !!diff, }) } // 8. Yield result const data = { filePath: file_path, oldString: actualOldString, newString: new_string, originalFile: originalFileContents, structuredPatch: patch, userModified: userModified ?? false, replaceAll: replace_all, ...(gitDiff && { gitDiff }), } return { data, } }, mapToolResultToToolResultBlockParam(data: FileEditOutput, toolUseID) { const { filePath, userModified, replaceAll } = data const modifiedNote = userModified ? '. The user modified your proposed changes before accepting them. ' : '' if (replaceAll) { return { tool_use_id: toolUseID, type: 'tool_result', content: `The file ${filePath} has been updated${modifiedNote}. All occurrences were successfully replaced.`, } } return { tool_use_id: toolUseID, type: 'tool_result', content: `The file ${filePath} has been updated successfully${modifiedNote}.`, } }, } satisfies ToolDef, FileEditOutput>) // -- function readFileForEdit(absoluteFilePath: string): { content: string fileExists: boolean encoding: BufferEncoding lineEndings: LineEndingType } { try { // eslint-disable-next-line custom-rules/no-sync-fs const meta = readFileSyncWithMetadata(absoluteFilePath) return { content: meta.content, fileExists: true, encoding: meta.encoding, lineEndings: meta.lineEndings, } } catch (e) { if (isENOENT(e)) { return { content: '', fileExists: false, encoding: 'utf8', lineEndings: 'LF', } } throw e } }