source dump of claude code
at main 625 lines 20 kB view raw
1import { dirname, isAbsolute, sep } from 'path' 2import { logEvent } from 'src/services/analytics/index.js' 3import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4import { diagnosticTracker } from '../../services/diagnosticTracking.js' 5import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js' 6import { getLspServerManager } from '../../services/lsp/manager.js' 7import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js' 8import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js' 9import { 10 activateConditionalSkillsForPaths, 11 addSkillDirectories, 12 discoverSkillDirsForPaths, 13} from '../../skills/loadSkillsDir.js' 14import type { ToolUseContext } from '../../Tool.js' 15import { buildTool, type ToolDef } from '../../Tool.js' 16import { getCwd } from '../../utils/cwd.js' 17import { logForDebugging } from '../../utils/debug.js' 18import { countLinesChanged } from '../../utils/diff.js' 19import { isEnvTruthy } from '../../utils/envUtils.js' 20import { isENOENT } from '../../utils/errors.js' 21import { 22 FILE_NOT_FOUND_CWD_NOTE, 23 findSimilarFile, 24 getFileModificationTime, 25 suggestPathUnderCwd, 26 writeTextContent, 27} from '../../utils/file.js' 28import { 29 fileHistoryEnabled, 30 fileHistoryTrackEdit, 31} from '../../utils/fileHistory.js' 32import { logFileOperation } from '../../utils/fileOperationAnalytics.js' 33import { 34 type LineEndingType, 35 readFileSyncWithMetadata, 36} from '../../utils/fileRead.js' 37import { formatFileSize } from '../../utils/format.js' 38import { getFsImplementation } from '../../utils/fsOperations.js' 39import { 40 fetchSingleFileGitDiff, 41 type ToolUseDiff, 42} from '../../utils/gitDiff.js' 43import { logError } from '../../utils/log.js' 44import { expandPath } from '../../utils/path.js' 45import { 46 checkWritePermissionForTool, 47 matchingRuleForInput, 48} from '../../utils/permissions/filesystem.js' 49import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 50import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 51import { validateInputForSettingsFileEdit } from '../../utils/settings/validateEditTool.js' 52import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js' 53import { 54 FILE_EDIT_TOOL_NAME, 55 FILE_UNEXPECTEDLY_MODIFIED_ERROR, 56} from './constants.js' 57import { getEditToolDescription } from './prompt.js' 58import { 59 type FileEditInput, 60 type FileEditOutput, 61 inputSchema, 62 outputSchema, 63} from './types.js' 64import { 65 getToolUseSummary, 66 renderToolResultMessage, 67 renderToolUseErrorMessage, 68 renderToolUseMessage, 69 renderToolUseRejectedMessage, 70 userFacingName, 71} from './UI.js' 72import { 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. 84const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat bytes) 85 86export 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 599function 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}