CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
edit.ts915 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace