basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as fs from 'node:fs';8import * as path from 'node:path';9import type {10 ToolCallConfirmationDetails,11 ToolEditConfirmationDetails,12 ToolInvocation,13 ToolLocation,14 ToolResult,15} from './tools.js';16import type { PermissionDecision } from '../permissions/types.js';17import { BaseDeclarativeTool, Kind, ToolConfirmationOutcome } from './tools.js';18import { ToolErrorType } from './tool-error.js';19import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js';20import { getErrorMessage, isNodeError } from '../utils/errors.js';21import type { Config } from '../config/config.js';22import { ApprovalMode } from '../config/config.js';23import { isAnyAutoMemPath, isTeamAutoMemPath } from '../memory/paths.js';24import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js';25import {26 FileEncoding,27 needsUtf8Bom,28 detectLineEnding,29} from '../services/fileSystemService.js';30import type { LineEnding } from '../services/fileSystemService.js';31import { createPatchSmart, getDiffStat } from './diffOptions.js';32import { checkPriorRead, StructuredToolError } from './priorReadEnforcement.js';33import { ReadFileTool } from './read-file.js';34import { createDebugLogger } from '../utils/debugLogger.js';35import { ToolNames, ToolDisplayNames } from './tool-names.js';36import { logFileOperation } from '../telemetry/loggers.js';37import { FileOperationEvent } from '../telemetry/types.js';38import { FileOperation } from '../telemetry/metrics.js';39import {40 getSpecificMimeType,41 fileExists as isFilefileExists,42} from '../utils/fileUtils.js';43import { getLanguageFromFilePath } from '../utils/language-detection.js';44import type {45 ModifiableDeclarativeTool,46 ModifyContext,47} from './modifiable-tool.js';48import { CommitAttributionService } from '../services/commitAttribution.js';49import { safeLiteralReplace } from '../utils/textUtils.js';50import {51 countOccurrences,52 extractEditSnippet,53 maybeAugmentOldStringForDeletion,54 normalizeEditStrings,55} from '../utils/editHelper.js';56 57const debugLogger = createDebugLogger('EDIT_PRIOR_READ');58 59export function applyReplacement(60 currentContent: string | null,61 oldString: string,62 newString: string,63 isNewFile: boolean,64): string {65 if (isNewFile) {66 return newString;67 }68 if (currentContent === null) {69 // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty70 return oldString === '' ? newString : '';71 }72 // If oldString is empty and it's not a new file, do not modify the content.73 if (oldString === '' && !isNewFile) {74 return currentContent;75 }76 77 // Use intelligent replacement that handles $ sequences safely78 return safeLiteralReplace(currentContent, oldString, newString);79}80 81/**82 * Parameters for the Edit tool83 */84export interface EditToolParams {85 /**86 * The absolute path to the file to modify87 */88 file_path: string;89 90 /**91 * The text to replace92 */93 old_string: string;94 95 /**96 * The text to replace it with97 */98 new_string: string;99 100 /**101 * Replace every occurrence of old_string instead of requiring a unique match.102 */103 replace_all?: boolean;104 105 /**106 * Whether the edit was modified manually by the user.107 */108 modified_by_user?: boolean;109 110 /**111 * Initially proposed content.112 */113 ai_proposed_content?: string;114}115 116interface CalculatedEdit {117 currentContent: string | null;118 newContent: string;119 occurrences: number;120 error?: { display: string; raw: string; type: ToolErrorType };121 isNewFile: boolean;122 /** Detected encoding of the existing file (e.g. 'utf-8', 'gbk') */123 encoding: string;124 /** Whether the existing file has a UTF-8 BOM */125 bom: boolean;126 /** Original line ending style of the existing file */127 lineEnding: LineEnding;128}129 130class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {131 constructor(132 private readonly config: Config,133 public params: EditToolParams,134 ) {}135 136 toolLocations(): ToolLocation[] {137 return [{ path: this.params.file_path }];138 }139 140 /**141 * Calculates the potential outcome of an edit operation.142 * @param params Parameters for the edit operation143 * @returns An object describing the potential edit outcome144 * @throws File system errors if reading the file fails unexpectedly (e.g., permissions)145 */146 private async calculateEdit(params: EditToolParams): Promise<CalculatedEdit> {147 const replaceAll = params.replace_all ?? false;148 let currentContent: string | null = null;149 let fileExists = await isFilefileExists(params.file_path);150 let isNewFile = false;151 let finalNewString = params.new_string;152 let finalOldString = params.old_string;153 let occurrences = 0;154 let error:155 | { display: string; raw: string; type: ToolErrorType }156 | undefined = undefined;157 let useBOM = false;158 let detectedEncoding = 'utf-8';159 let detectedLineEnding: LineEnding = 'lf';160 // Prior-read enforcement runs before any content is read so that161 // the read pipeline below (and the content-derived error codes162 // it can produce — NO_OCCURRENCE_FOUND, EXPECTED_OCCURRENCE_MISMATCH,163 // NO_CHANGE) cannot be used as a read-less content oracle on a164 // file the model has never legitimately Read.165 //166 // Run unconditionally (not gated on `fileExists`): checkPriorRead167 // re-stats so a file that sprang into existence between168 // isFilefileExists() and here — the same TOCTOU window WriteFile169 // had — is now caught. ENOENT (genuinely absent) returns ok:true170 // and falls through to the new-file path; an existing file that171 // appeared in the race window is rejected as unread.172 if (!this.config.getFileReadCacheDisabled()) {173 const decision = await checkPriorRead(174 this.config.getFileReadCache(),175 params.file_path,176 'editing',177 );178 if (!decision.ok) {179 return {180 currentContent: null,181 newContent: '',182 occurrences: 0,183 error: {184 display: decision.displayMessage,185 raw: decision.rawMessage,186 type: decision.type,187 },188 isNewFile: false,189 encoding: 'utf-8',190 bom: false,191 lineEnding: 'lf',192 };193 }194 }195 if (fileExists) {196 try {197 const fileInfo = await this.config198 .getFileSystemService()199 .readTextFile({ path: params.file_path });200 if (fileInfo._meta?.bom !== undefined) {201 useBOM = fileInfo._meta.bom;202 } else {203 useBOM =204 fileInfo.content.length > 0 &&205 fileInfo.content.codePointAt(0) === 0xfeff;206 }207 detectedEncoding = fileInfo._meta?.encoding || 'utf-8';208 // Detect original line ending style before normalizing209 detectedLineEnding = detectLineEnding(fileInfo.content);210 // Normalize line endings to LF for consistent processing.211 currentContent = fileInfo.content.replace(/\r\n/g, '\n');212 fileExists = true;213 // Encoding and BOM are returned from the same I/O pass, avoiding redundant reads.214 } catch (err: unknown) {215 if (!isNodeError(err) || err.code !== 'ENOENT') {216 // Rethrow unexpected FS errors (permissions, etc.)217 throw err;218 }219 fileExists = false;220 }221 }222 223 // Post-read freshness re-check. The pre-read checkPriorRead above224 // and readTextFile are two separate syscalls; if the file is225 // modified between them, currentContent reflects post-write bytes226 // the model never saw and any edit applied to it would still be227 // a stale-write. Re-running checkPriorRead here closes the TOCTOU228 // window: a stale state now (mtime/size drifted) means we read229 // bytes the cache no longer trusts, and we reject before230 // returning a CalculatedEdit that the call sites would honour.231 if (fileExists && !this.config.getFileReadCacheDisabled()) {232 const postDecision = await checkPriorRead(233 this.config.getFileReadCache(),234 params.file_path,235 'editing',236 { expectExisting: true },237 );238 if (!postDecision.ok) {239 // Forensic trail for post-read TOCTOU rejections. These are240 // rare ("file changed between stat and read") and the model241 // self-heals by re-reading, so without a debug record an242 // operator investigating "why did this Edit fail once?" has243 // nothing to grep.244 debugLogger.warn('post-read TOCTOU rejection', {245 path: params.file_path,246 reason: postDecision.type,247 });248 return {249 currentContent: null,250 newContent: '',251 occurrences: 0,252 error: {253 display: postDecision.displayMessage,254 raw: postDecision.rawMessage,255 type: postDecision.type,256 },257 isNewFile: false,258 encoding: 'utf-8',259 bom: false,260 lineEnding: 'lf',261 };262 }263 }264 265 const normalizedStrings = normalizeEditStrings(266 currentContent,267 finalOldString,268 finalNewString,269 );270 finalOldString = normalizedStrings.oldString;271 finalNewString = normalizedStrings.newString;272 273 if (finalOldString === '' && !fileExists) {274 // Creating a new file275 isNewFile = true;276 } else if (!fileExists) {277 // Trying to edit a nonexistent file (and old_string is not empty)278 error = {279 display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`,280 raw: `File not found: ${params.file_path}`,281 type: ToolErrorType.FILE_NOT_FOUND,282 };283 } else if (currentContent !== null) {284 finalOldString = maybeAugmentOldStringForDeletion(285 currentContent,286 finalOldString,287 finalNewString,288 );289 290 occurrences = countOccurrences(currentContent, finalOldString);291 if (params.old_string === '') {292 // Error: Trying to create a file that already exists293 error = {294 display: `Failed to edit. Attempted to create a file that already exists.`,295 raw: `File already exists, cannot create: ${params.file_path}`,296 type: ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE,297 };298 } else if (occurrences === 0) {299 error = {300 display: `Failed to edit, could not find the string to replace.`,301 raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`,302 type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,303 };304 } else if (!replaceAll && occurrences > 1) {305 error = {306 display: `Failed to edit because the text matches multiple locations. Provide more context or set replace_all to true.`,307 raw: `Failed to edit. Found ${occurrences} occurrences for old_string in ${params.file_path} but replace_all was not enabled.`,308 type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,309 };310 } else if (finalOldString === finalNewString) {311 error = {312 display: `No changes to apply. The old_string and new_string are identical.`,313 raw: `No changes to apply. The old_string and new_string are identical in file: ${params.file_path}`,314 type: ToolErrorType.EDIT_NO_CHANGE,315 };316 }317 } else {318 // Should not happen if fileExists and no exception was thrown, but defensively:319 error = {320 display: `Failed to read content of file.`,321 raw: `Failed to read content of existing file: ${params.file_path}`,322 type: ToolErrorType.READ_CONTENT_FAILURE,323 };324 }325 326 const newContent = !error327 ? applyReplacement(328 currentContent,329 finalOldString,330 finalNewString,331 isNewFile,332 )333 : (currentContent ?? '');334 335 if (!error && fileExists && currentContent === newContent) {336 error = {337 display:338 'No changes to apply. The new content is identical to the current content.',339 raw: `No changes to apply. The new content is identical to the current content in file: ${params.file_path}`,340 type: ToolErrorType.EDIT_NO_CHANGE,341 };342 }343 344 // Scan the full resulting content, not just new_string, so a secret split345 // across multiple edits (each fragment alone undetectable) is still caught.346 if (!error) {347 const teamMemoryError = checkTeamMemorySecrets(348 params.file_path,349 newContent,350 this.config.getProjectRoot(),351 );352 if (teamMemoryError) {353 // If the secret is already in the on-disk file, this edit can't clear it354 // — tell the user to remove the committed secret, not just retry.355 const preExisting =356 currentContent !== null &&357 checkTeamMemorySecrets(358 params.file_path,359 currentContent,360 this.config.getProjectRoot(),361 ) !== null;362 const message = preExisting363 ? `${teamMemoryError} Note: the secret already exists in the current file content, so removing it from your edit alone is not enough — delete the committed secret from the file.`364 : teamMemoryError;365 error = {366 display: message,367 raw: message,368 type: ToolErrorType.INVALID_TOOL_PARAMS,369 };370 }371 }372 373 return {374 currentContent,375 newContent,376 occurrences,377 error,378 isNewFile,379 bom: useBOM,380 encoding: detectedEncoding,381 lineEnding: detectedLineEnding,382 };383 }384 385 /**386 * Edit operations always need user confirmation, except for the private387 * managed auto-memory files (user/project) which are written autonomously.388 * Team memory is shared and committed to the repo, so it is NOT auto-allowed389 * like the private tiers — edits default to 'ask'. (In AUTO_EDIT/YOLO the user390 * has globally opted into auto-approval; team writes still surface in the git391 * diff for review before commit.)392 */393 async getDefaultPermission(): Promise<PermissionDecision> {394 const projectRoot = this.config.getProjectRoot();395 const filePath = path.resolve(this.params.file_path);396 if (isTeamAutoMemPath(filePath, projectRoot)) {397 return 'ask';398 }399 if (isAnyAutoMemPath(filePath, projectRoot)) {400 return 'allow';401 }402 return 'ask';403 }404 405 /**406 * Constructs the edit diff confirmation details.407 */408 async getConfirmationDetails(409 abortSignal: AbortSignal,410 ): Promise<ToolCallConfirmationDetails> {411 let editData: CalculatedEdit;412 try {413 editData = await this.calculateEdit(this.params);414 } catch (error) {415 if (abortSignal.aborted) {416 throw error;417 }418 const errorMsg = getErrorMessage(error);419 throw new Error(`Error preparing edit: ${errorMsg}`);420 }421 422 if (editData.error) {423 // Use the full `raw` message, not the short `display` form:424 // the scheduler propagates `error.message` straight into the425 // model-facing tool response. `raw` carries the remediation426 // detail (file path, stale-vs-unread distinction, "without427 // offset / limit / pages" hint) that `execute()` already428 // surfaces — confirmation-required flows should not lose it.429 throw new StructuredToolError(editData.error.raw, editData.error.type);430 }431 432 const fileName = path.basename(this.params.file_path);433 const fileDiff = createPatchSmart(434 fileName,435 editData.currentContent ?? '',436 editData.newContent,437 'Current',438 'Proposed',439 );440 const confirmationDetails: ToolEditConfirmationDetails = {441 type: 'edit',442 title: `Confirm Edit: ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`,443 fileName,444 filePath: this.params.file_path,445 fileDiff,446 originalContent: editData.currentContent,447 newContent: editData.newContent,448 onConfirm: async (outcome: ToolConfirmationOutcome) => {449 if (outcome === ToolConfirmationOutcome.ProceedAlways) {450 this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);451 }452 },453 };454 return confirmationDetails;455 }456 457 getDescription(): string {458 const relativePath = makeRelative(459 this.params.file_path,460 this.config.getTargetDir(),461 );462 if (this.params.old_string === '') {463 return `Create ${shortenPath(relativePath)}`;464 }465 466 if (this.params.old_string === this.params.new_string) {467 return `No file changes to ${shortenPath(relativePath)}`;468 }469 return shortenPath(relativePath);470 }471 472 /**473 * Executes the edit operation with the given parameters.474 * @param params Parameters for the edit operation475 * @returns Result of the edit operation476 */477 async execute(signal: AbortSignal): Promise<ToolResult> {478 let editData: CalculatedEdit;479 try {480 editData = await this.calculateEdit(this.params);481 } catch (error) {482 if (signal.aborted) {483 throw error;484 }485 const errorMsg = getErrorMessage(error);486 return {487 llmContent: `Error preparing edit: ${errorMsg}`,488 returnDisplay: `Error preparing edit: ${errorMsg}`,489 error: {490 message: errorMsg,491 type: ToolErrorType.EDIT_PREPARATION_FAILURE,492 },493 };494 }495 496 if (editData.error) {497 return {498 llmContent: editData.error.raw,499 returnDisplay: `Error: ${editData.error.display}`,500 error: {501 message: editData.error.raw,502 type: editData.error.type,503 },504 };505 }506 507 try {508 // Backup the pre-edit content BEFORE the final freshness check.509 // Mirrors the upstream `claude-code/src/tools/FileEditTool` ordering,510 // which has an explicit comment on the equivalent block:511 //512 // "These awaits must stay OUTSIDE the critical section below — a513 // yield between the staleness check and writeTextContent lets514 // concurrent edits interleave."515 //516 // `trackEdit` does `stat` + `copyFile` and on large files can take517 // hundreds of milliseconds. The previous ordering ran it AFTER518 // `checkPriorRead` and before `writeTextFile`, which widened the519 // already-acknowledged stat-then-write window from "two adjacent520 // syscalls" to "freshness check → potentially-multi-second backup →521 // write". An external mutation landing inside the backup window was522 // therefore no longer detected before the write clobbered it.523 //524 // Backing up first is safe: backups are idempotent (deterministic525 // `{hash}@v{version}` filename) and per-snapshot. If the freshness526 // check below then rejects the edit, we keep an unused-but-correct527 // backup of the pre-edit state — not corrupt state. The next528 // makeSnapshot will reuse it if the file is unchanged.529 try {530 await this.config531 .getFileHistoryService()532 .trackEdit(this.params.file_path);533 } catch {534 // File history is best-effort; never block core tool operations.535 }536 537 // Final pre-write freshness check. calculateEdit() ran a538 // post-read check, but execute() can be called arbitrarily539 // long after that (user approval, modify-and-confirm, etc.).540 // Between the post-read check and the writeTextFile below,541 // an external mutation could land and be silently overwritten.542 // This last guard tightens the window from "post-read →543 // writeTextFile (unbounded)" to "stat → writeTextFile (two544 // adjacent syscalls)".545 //546 // It does NOT eliminate the race. A concurrent writer that547 // lands between this stat and the writeTextFile call below548 // can still be clobbered — that residual is an OS-level549 // limitation of the stat-then-write pattern, and the only550 // way to close it is an atomic write (write to a temp file,551 // then rename) or a content-hash post-check that re-reads552 // the bytes after the write. Both are deferred to a follow-up553 // PR; operators who care about strict overwrite-protection554 // should set `fileReadCacheDisabled: true` and rely on555 // application-level locking.556 //557 // Run unconditionally (not gated on `editData.isNewFile`):558 // `isNewFile` was decided back in calculateEdit, but a file559 // could be created in the gap between then and now and a560 // confirmation-pending Edit would otherwise clobber it561 // without enforcement. ENOENT inside checkPriorRead returns562 // ok:true so genuine new-file creation is unaffected.563 if (!this.config.getFileReadCacheDisabled()) {564 const writeDecision = await checkPriorRead(565 this.config.getFileReadCache(),566 this.params.file_path,567 'editing',568 // For an in-place edit (`!isNewFile`), the file existed at569 // read time and must still exist now — an ENOENT here570 // means the original target disappeared and we should571 // reject rather than fall through to a new-file write572 // that would silently re-create a file from stale bytes.573 // For genuine new-file creation, ENOENT is the expected574 // pre-write state (ok:true → writeTextFile creates).575 { expectExisting: !editData.isNewFile },576 );577 if (!writeDecision.ok) {578 debugLogger.warn('pre-write TOCTOU rejection', {579 path: this.params.file_path,580 reason: writeDecision.type,581 });582 return {583 llmContent: writeDecision.rawMessage,584 returnDisplay: `Error: ${writeDecision.displayMessage}`,585 error: {586 message: writeDecision.rawMessage,587 type: writeDecision.type,588 },589 };590 }591 }592 593 // Create parent directories AFTER the pre-write enforcement594 // check passes. Doing it before would leak intermediate595 // directories on the failure path — a real (if minor) FS596 // litter that the previous order created on every rejected597 // edit.598 this.ensureParentDirectoriesExist(this.params.file_path);599 600 // For new files, apply default file encoding setting601 // For existing files, preserve the original encoding (BOM and charset)602 if (editData.isNewFile) {603 const userEncoding = this.config.getDefaultFileEncoding();604 let useBOM = false;605 if (userEncoding === FileEncoding.UTF8_BOM) {606 useBOM = true;607 } else if (userEncoding === undefined) {608 // No explicit setting: auto-detect (e.g. .ps1 on non-UTF-8 Windows)609 useBOM = needsUtf8Bom(this.params.file_path);610 }611 await this.config.getFileSystemService().writeTextFile({612 path: this.params.file_path,613 content: editData.newContent,614 _meta: {615 bom: useBOM,616 },617 });618 } else {619 await this.config.getFileSystemService().writeTextFile({620 path: this.params.file_path,621 content: editData.newContent,622 _meta: {623 bom: editData.bom,624 encoding: editData.encoding,625 lineEnding: editData.lineEnding,626 },627 });628 }629 630 // Track AI contribution for commit attribution631 if (!this.params.modified_by_user) {632 CommitAttributionService.getInstance().recordEdit(633 this.params.file_path,634 editData.currentContent,635 editData.newContent,636 );637 }638 639 // Mark the cache entry written, capturing the post-write stats640 // so a follow-up Read sees `lastReadAt < lastWriteAt` and falls641 // through to the full pipeline instead of returning the642 // pre-edit placeholder. Best-effort: a stat failure here does643 // not undo the successful write — the next Read will simply644 // re-stat and treat the cache entry as stale.645 try {646 const postWriteStats = fs.statSync(this.params.file_path);647 this.config648 .getFileReadCache()649 .recordWrite(this.params.file_path, postWriteStats);650 } catch {651 // Non-fatal: leaving a stale entry is preferable to failing652 // the user-visible Edit on a transient stat failure. The653 // entry's mtime/size still does not match the on-disk bytes654 // post-write, so the next ReadFile will report stale and655 // refresh the entry.656 }657 658 const fileName = path.basename(this.params.file_path);659 const originallyProposedContent =660 this.params.ai_proposed_content || editData.newContent;661 const diffStat = getDiffStat(662 fileName,663 editData.currentContent ?? '',664 originallyProposedContent,665 editData.newContent,666 );667 668 const fileDiff = createPatchSmart(669 fileName,670 editData.currentContent ?? '',671 editData.newContent,672 'Current',673 'Proposed',674 );675 const displayResult = {676 fileDiff,677 fileName,678 originalContent: editData.currentContent,679 newContent: editData.newContent,680 diffStat,681 };682 683 // Log file operation for telemetry (without diff_stat to avoid double-counting)684 const mimetype = getSpecificMimeType(this.params.file_path);685 const programmingLanguage = getLanguageFromFilePath(686 this.params.file_path,687 );688 const extension = path.extname(this.params.file_path);689 const operation = editData.isNewFile690 ? FileOperation.CREATE691 : FileOperation.UPDATE;692 693 logFileOperation(694 this.config,695 new FileOperationEvent(696 EditTool.Name,697 operation,698 editData.newContent.split('\n').length,699 mimetype,700 extension,701 programmingLanguage,702 ),703 );704 705 const llmSuccessMessageParts = [706 editData.isNewFile707 ? `Created new file: ${this.params.file_path} with provided content.`708 : `The file: ${this.params.file_path} has been updated.`,709 ];710 711 const snippetResult = extractEditSnippet(712 editData.currentContent,713 editData.newContent,714 );715 if (snippetResult) {716 const snippetText = `Showing lines ${snippetResult.startLine}-${snippetResult.endLine} of ${snippetResult.totalLines} from the edited file:\n\n---\n\n${snippetResult.content}`;717 llmSuccessMessageParts.push(snippetText);718 }719 720 return {721 llmContent: llmSuccessMessageParts.join(' '),722 returnDisplay: displayResult,723 };724 } catch (error) {725 const errorMsg = getErrorMessage(error);726 return {727 llmContent: `Error executing edit: ${errorMsg}`,728 returnDisplay: `Error writing file: ${errorMsg}`,729 error: {730 message: errorMsg,731 type: ToolErrorType.FILE_WRITE_FAILURE,732 },733 };734 }735 }736 737 /**738 * Creates parent directories if they don't exist739 */740 private ensureParentDirectoriesExist(filePath: string): void {741 const dirName = path.dirname(filePath);742 if (!fs.existsSync(dirName)) {743 fs.mkdirSync(dirName, { recursive: true });744 }745 }746}747 748/**749 * Implementation of the Edit tool logic750 */751export class EditTool752 extends BaseDeclarativeTool<EditToolParams, ToolResult>753 implements ModifiableDeclarativeTool<EditToolParams>754{755 static readonly Name = ToolNames.EDIT;756 constructor(private readonly config: Config) {757 super(758 EditTool.Name,759 ToolDisplayNames.EDIT,760 `Replaces text within a file. By default, replaces a single occurrence. Set \`replace_all\` to true when you intend to modify every instance of \`old_string\`. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement.761 762 The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.763 764Expectation for required parameters:7651. \`file_path\` MUST be an absolute path; otherwise an error will be thrown.7662. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.).7673. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic.7684. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.769**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail.770**Multiple replacements:** Set \`replace_all\` to true when you want to replace every occurrence that matches \`old_string\`.`,771 Kind.Edit,772 {773 properties: {774 file_path: {775 description:776 "The absolute path to the file to modify. Must start with '/'.",777 type: 'string',778 },779 old_string: {780 description:781 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',782 type: 'string',783 },784 new_string: {785 description:786 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',787 type: 'string',788 },789 replace_all: {790 type: 'boolean',791 description:792 'Replace all occurrences of old_string (default false).',793 },794 },795 required: ['file_path', 'old_string', 'new_string'],796 type: 'object',797 },798 );799 }800 801 /**802 * Validates the parameters for the Edit tool803 * @param params Parameters to validate804 * @returns Error message string or null if valid805 */806 protected override validateToolParamValues(807 params: EditToolParams,808 ): string | null {809 // Normalize shell-escaped paths (e.g. "my\ file.txt" → "my file.txt")810 // that may reach the LLM via at-completion or manual typing.811 params.file_path = unescapePath(params.file_path.trim());812 813 if (!params.file_path) {814 return "The 'file_path' parameter must be non-empty.";815 }816 817 if (!path.isAbsolute(params.file_path)) {818 return `File path must be absolute: ${params.file_path}`;819 }820 821 const teamMemoryError = checkTeamMemorySecrets(822 params.file_path,823 params.new_string ?? '',824 this.config.getProjectRoot(),825 );826 if (teamMemoryError) {827 return teamMemoryError;828 }829 830 return null;831 }832 833 protected createInvocation(834 params: EditToolParams,835 ): ToolInvocation<EditToolParams, ToolResult> {836 return new EditToolInvocation(this.config, params);837 }838 839 override toAutoClassifierInput(840 params: EditToolParams,841 ): Record<string, unknown> {842 const oldStr = params.old_string ?? '';843 const newStr = params.new_string ?? '';844 // 300 chars is enough headroom for the classifier to spot a malicious845 // registry / shell / env line that hides behind a benign-looking846 // prefix (~80 chars). In-workspace edits take the acceptEdits fast-847 // path and never reach this projection; the preview is therefore848 // only consulted for the smaller set of out-of-workspace writes849 // (~/.npmrc, /etc/hosts, etc.) — exactly the case where the850 // classifier needs the longer window.851 return {852 file_path: params.file_path,853 old_string_preview: oldStr.slice(0, 300),854 new_string_preview: newStr.slice(0, 300),855 old_string_truncated: oldStr.length > 300,856 new_string_truncated: newStr.length > 300,857 lines_changed:858 (newStr.match(/\n/g)?.length ?? 0) - (oldStr.match(/\n/g)?.length ?? 0),859 };860 }861 862 getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> {863 return {864 getFilePath: (params: EditToolParams) => params.file_path,865 getCurrentContent: async (params: EditToolParams): Promise<string> => {866 const fileExists = await isFilefileExists(params.file_path);867 if (fileExists) {868 try {869 const { content } = await this.config870 .getFileSystemService()871 .readTextFile({ path: params.file_path });872 return content;873 } catch (err) {874 if (!isNodeError(err) || err.code !== 'ENOENT') throw err;875 return '';876 }877 } else {878 return '';879 }880 },881 getProposedContent: async (params: EditToolParams): Promise<string> => {882 if (fs.existsSync(params.file_path)) {883 try {884 const { content: currentContent } = await this.config885 .getFileSystemService()886 .readTextFile({ path: params.file_path });887 return applyReplacement(888 currentContent,889 params.old_string,890 params.new_string,891 params.old_string === '' && currentContent === '',892 );893 } catch (err) {894 if (!isNodeError(err) || err.code !== 'ENOENT') throw err;895 return '';896 }897 } else {898 return '';899 }900 },901 createUpdatedParams: (902 oldContent: string,903 modifiedProposedContent: string,904 originalParams: EditToolParams,905 ): EditToolParams => ({906 ...originalParams,907 ai_proposed_content: oldContent,908 old_string: oldContent,909 new_string: modifiedProposedContent,910 modified_by_user: true,911 }),912 };913 }914}915 