/ tools / FileWriteTool / FileWriteTool.ts
FileWriteTool.ts
  1  import { dirname, sep } from 'path'
  2  import { logEvent } from 'src/services/analytics/index.js'
  3  import { z } from 'zod/v4'
  4  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  5  import { diagnosticTracker } from '../../services/diagnosticTracking.js'
  6  import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js'
  7  import { getLspServerManager } from '../../services/lsp/manager.js'
  8  import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js'
  9  import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js'
 10  import {
 11    activateConditionalSkillsForPaths,
 12    addSkillDirectories,
 13    discoverSkillDirsForPaths,
 14  } from '../../skills/loadSkillsDir.js'
 15  import type { ToolUseContext } from '../../Tool.js'
 16  import { buildTool, type ToolDef } from '../../Tool.js'
 17  import { getCwd } from '../../utils/cwd.js'
 18  import { logForDebugging } from '../../utils/debug.js'
 19  import { countLinesChanged, getPatchForDisplay } from '../../utils/diff.js'
 20  import { isEnvTruthy } from '../../utils/envUtils.js'
 21  import { isENOENT } from '../../utils/errors.js'
 22  import { getFileModificationTime, writeTextContent } from '../../utils/file.js'
 23  import {
 24    fileHistoryEnabled,
 25    fileHistoryTrackEdit,
 26  } from '../../utils/fileHistory.js'
 27  import { logFileOperation } from '../../utils/fileOperationAnalytics.js'
 28  import { readFileSyncWithMetadata } from '../../utils/fileRead.js'
 29  import { getFsImplementation } from '../../utils/fsOperations.js'
 30  import {
 31    fetchSingleFileGitDiff,
 32    type ToolUseDiff,
 33  } from '../../utils/gitDiff.js'
 34  import { lazySchema } from '../../utils/lazySchema.js'
 35  import { logError } from '../../utils/log.js'
 36  import { expandPath } from '../../utils/path.js'
 37  import {
 38    checkWritePermissionForTool,
 39    matchingRuleForInput,
 40  } from '../../utils/permissions/filesystem.js'
 41  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
 42  import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
 43  import { FILE_UNEXPECTEDLY_MODIFIED_ERROR } from '../FileEditTool/constants.js'
 44  import { gitDiffSchema, hunkSchema } from '../FileEditTool/types.js'
 45  import { FILE_WRITE_TOOL_NAME, getWriteToolDescription } from './prompt.js'
 46  import {
 47    getToolUseSummary,
 48    isResultTruncated,
 49    renderToolResultMessage,
 50    renderToolUseErrorMessage,
 51    renderToolUseMessage,
 52    renderToolUseRejectedMessage,
 53    userFacingName,
 54  } from './UI.js'
 55  
 56  const inputSchema = lazySchema(() =>
 57    z.strictObject({
 58      file_path: z
 59        .string()
 60        .describe(
 61          'The absolute path to the file to write (must be absolute, not relative)',
 62        ),
 63      content: z.string().describe('The content to write to the file'),
 64    }),
 65  )
 66  type InputSchema = ReturnType<typeof inputSchema>
 67  
 68  const outputSchema = lazySchema(() =>
 69    z.object({
 70      type: z
 71        .enum(['create', 'update'])
 72        .describe(
 73          'Whether a new file was created or an existing file was updated',
 74        ),
 75      filePath: z.string().describe('The path to the file that was written'),
 76      content: z.string().describe('The content that was written to the file'),
 77      structuredPatch: z
 78        .array(hunkSchema())
 79        .describe('Diff patch showing the changes'),
 80      originalFile: z
 81        .string()
 82        .nullable()
 83        .describe(
 84          'The original file content before the write (null for new files)',
 85        ),
 86      gitDiff: gitDiffSchema().optional(),
 87    }),
 88  )
 89  type OutputSchema = ReturnType<typeof outputSchema>
 90  
 91  export type Output = z.infer<OutputSchema>
 92  export type FileWriteToolInput = InputSchema
 93  
 94  export const FileWriteTool = buildTool({
 95    name: FILE_WRITE_TOOL_NAME,
 96    searchHint: 'create or overwrite files',
 97    maxResultSizeChars: 100_000,
 98    strict: true,
 99    async description() {
100      return 'Write a file to the local filesystem.'
101    },
102    userFacingName,
103    getToolUseSummary,
104    getActivityDescription(input) {
105      const summary = getToolUseSummary(input)
106      return summary ? `Writing ${summary}` : 'Writing file'
107    },
108    async prompt() {
109      return getWriteToolDescription()
110    },
111    renderToolUseMessage,
112    isResultTruncated,
113    get inputSchema(): InputSchema {
114      return inputSchema()
115    },
116    get outputSchema(): OutputSchema {
117      return outputSchema()
118    },
119    toAutoClassifierInput(input) {
120      return `${input.file_path}: ${input.content}`
121    },
122    getPath(input): string {
123      return input.file_path
124    },
125    backfillObservableInput(input) {
126      // hooks.mdx documents file_path as absolute; expand so hook allowlists
127      // can't be bypassed via ~ or relative paths.
128      if (typeof input.file_path === 'string') {
129        input.file_path = expandPath(input.file_path)
130      }
131    },
132    async preparePermissionMatcher({ file_path }) {
133      return pattern => matchWildcardPattern(pattern, file_path)
134    },
135    async checkPermissions(input, context): Promise<PermissionDecision> {
136      const appState = context.getAppState()
137      return checkWritePermissionForTool(
138        FileWriteTool,
139        input,
140        appState.toolPermissionContext,
141      )
142    },
143    renderToolUseRejectedMessage,
144    renderToolUseErrorMessage,
145    renderToolResultMessage,
146    extractSearchText() {
147      // Transcript render shows either content (create, via HighlightedCode)
148      // or a structured diff (update). The heuristic's 'content' allowlist key
149      // would index the raw content string even in update mode where it's NOT
150      // shown — phantom. Under-count: tool_use already indexes file_path.
151      return ''
152    },
153    async validateInput({ file_path, content }, toolUseContext: ToolUseContext) {
154      const fullFilePath = expandPath(file_path)
155  
156      // Reject writes to team memory files that contain secrets
157      const secretError = checkTeamMemSecrets(fullFilePath, content)
158      if (secretError) {
159        return { result: false, message: secretError, errorCode: 0 }
160      }
161  
162      // Check if path should be ignored based on permission settings
163      const appState = toolUseContext.getAppState()
164      const denyRule = matchingRuleForInput(
165        fullFilePath,
166        appState.toolPermissionContext,
167        'edit',
168        'deny',
169      )
170      if (denyRule !== null) {
171        return {
172          result: false,
173          message:
174            'File is in a directory that is denied by your permission settings.',
175          errorCode: 1,
176        }
177      }
178  
179      // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
180      // On Windows, fs.existsSync() on UNC paths triggers SMB authentication which could
181      // leak credentials to malicious servers. Let the permission check handle UNC paths.
182      if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
183        return { result: true }
184      }
185  
186      const fs = getFsImplementation()
187      let fileMtimeMs: number
188      try {
189        const fileStat = await fs.stat(fullFilePath)
190        fileMtimeMs = fileStat.mtimeMs
191      } catch (e) {
192        if (isENOENT(e)) {
193          return { result: true }
194        }
195        throw e
196      }
197  
198      const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
199      if (!readTimestamp || readTimestamp.isPartialView) {
200        return {
201          result: false,
202          message:
203            'File has not been read yet. Read it first before writing to it.',
204          errorCode: 2,
205        }
206      }
207  
208      // Reuse mtime from the stat above — avoids a redundant statSync via
209      // getFileModificationTime. The readTimestamp guard above ensures this
210      // block is always reached when the file exists.
211      const lastWriteTime = Math.floor(fileMtimeMs)
212      if (lastWriteTime > readTimestamp.timestamp) {
213        return {
214          result: false,
215          message:
216            'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
217          errorCode: 3,
218        }
219      }
220  
221      return { result: true }
222    },
223    async call(
224      { file_path, content },
225      { readFileState, updateFileHistoryState, dynamicSkillDirTriggers },
226      _,
227      parentMessage,
228    ) {
229      const fullFilePath = expandPath(file_path)
230      const dir = dirname(fullFilePath)
231  
232      // Discover skills from this file's path (fire-and-forget, non-blocking)
233      const cwd = getCwd()
234      const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd)
235      if (newSkillDirs.length > 0) {
236        // Store discovered dirs for attachment display
237        for (const dir of newSkillDirs) {
238          dynamicSkillDirTriggers?.add(dir)
239        }
240        // Don't await - let skill loading happen in the background
241        addSkillDirectories(newSkillDirs).catch(() => {})
242      }
243  
244      // Activate conditional skills whose path patterns match this file
245      activateConditionalSkillsForPaths([fullFilePath], cwd)
246  
247      await diagnosticTracker.beforeFileEdited(fullFilePath)
248  
249      // Ensure parent directory exists before the atomic read-modify-write section.
250      // Must stay OUTSIDE the critical section below (a yield between the staleness
251      // check and writeTextContent lets concurrent edits interleave), and BEFORE the
252      // write (lazy-mkdir-on-ENOENT would fire a spurious tengu_atomic_write_error
253      // inside writeFileSyncAndFlush_DEPRECATED before ENOENT propagates back).
254      await getFsImplementation().mkdir(dir)
255      if (fileHistoryEnabled()) {
256        // Backup captures pre-edit content — safe to call before the staleness
257        // check (idempotent v1 backup keyed on content hash; if staleness fails
258        // later we just have an unused backup, not corrupt state).
259        await fileHistoryTrackEdit(
260          updateFileHistoryState,
261          fullFilePath,
262          parentMessage.uuid,
263        )
264      }
265  
266      // Load current state and confirm no changes since last read.
267      // Please avoid async operations between here and writing to disk to preserve atomicity.
268      let meta: ReturnType<typeof readFileSyncWithMetadata> | null
269      try {
270        meta = readFileSyncWithMetadata(fullFilePath)
271      } catch (e) {
272        if (isENOENT(e)) {
273          meta = null
274        } else {
275          throw e
276        }
277      }
278  
279      if (meta !== null) {
280        const lastWriteTime = getFileModificationTime(fullFilePath)
281        const lastRead = readFileState.get(fullFilePath)
282        if (!lastRead || lastWriteTime > lastRead.timestamp) {
283          // Timestamp indicates modification, but on Windows timestamps can change
284          // without content changes (cloud sync, antivirus, etc.). For full reads,
285          // compare content as a fallback to avoid false positives.
286          const isFullRead =
287            lastRead &&
288            lastRead.offset === undefined &&
289            lastRead.limit === undefined
290          // meta.content is CRLF-normalized — matches readFileState's normalized form.
291          if (!isFullRead || meta.content !== lastRead.content) {
292            throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR)
293          }
294        }
295      }
296  
297      const enc = meta?.encoding ?? 'utf8'
298      const oldContent = meta?.content ?? null
299  
300      // Write is a full content replacement — the model sent explicit line endings
301      // in `content` and meant them. Do not rewrite them. Previously we preserved
302      // the old file's line endings (or sampled the repo via ripgrep for new
303      // files), which silently corrupted e.g. bash scripts with \r on Linux when
304      // overwriting a CRLF file or when binaries in cwd poisoned the repo sample.
305      writeTextContent(fullFilePath, content, enc, 'LF')
306  
307      // Notify LSP servers about file modification (didChange) and save (didSave)
308      const lspManager = getLspServerManager()
309      if (lspManager) {
310        // Clear previously delivered diagnostics so new ones will be shown
311        clearDeliveredDiagnosticsForFile(`file://${fullFilePath}`)
312        // didChange: Content has been modified
313        lspManager.changeFile(fullFilePath, content).catch((err: Error) => {
314          logForDebugging(
315            `LSP: Failed to notify server of file change for ${fullFilePath}: ${err.message}`,
316          )
317          logError(err)
318        })
319        // didSave: File has been saved to disk (triggers diagnostics in TypeScript server)
320        lspManager.saveFile(fullFilePath).catch((err: Error) => {
321          logForDebugging(
322            `LSP: Failed to notify server of file save for ${fullFilePath}: ${err.message}`,
323          )
324          logError(err)
325        })
326      }
327  
328      // Notify VSCode about the file change for diff view
329      notifyVscodeFileUpdated(fullFilePath, oldContent, content)
330  
331      // Update read timestamp, to invalidate stale writes
332      readFileState.set(fullFilePath, {
333        content,
334        timestamp: getFileModificationTime(fullFilePath),
335        offset: undefined,
336        limit: undefined,
337      })
338  
339      // Log when writing to CLAUDE.md
340      if (fullFilePath.endsWith(`${sep}CLAUDE.md`)) {
341        logEvent('tengu_write_claudemd', {})
342      }
343  
344      let gitDiff: ToolUseDiff | undefined
345      if (
346        isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
347        getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false)
348      ) {
349        const startTime = Date.now()
350        const diff = await fetchSingleFileGitDiff(fullFilePath)
351        if (diff) gitDiff = diff
352        logEvent('tengu_tool_use_diff_computed', {
353          isWriteTool: true,
354          durationMs: Date.now() - startTime,
355          hasDiff: !!diff,
356        })
357      }
358  
359      if (oldContent) {
360        const patch = getPatchForDisplay({
361          filePath: file_path,
362          fileContents: oldContent,
363          edits: [
364            {
365              old_string: oldContent,
366              new_string: content,
367              replace_all: false,
368            },
369          ],
370        })
371  
372        const data = {
373          type: 'update' as const,
374          filePath: file_path,
375          content,
376          structuredPatch: patch,
377          originalFile: oldContent,
378          ...(gitDiff && { gitDiff }),
379        }
380        // Track lines added and removed for file updates, right before yielding result
381        countLinesChanged(patch)
382  
383        logFileOperation({
384          operation: 'write',
385          tool: 'FileWriteTool',
386          filePath: fullFilePath,
387          type: 'update',
388        })
389  
390        return {
391          data,
392        }
393      }
394  
395      const data = {
396        type: 'create' as const,
397        filePath: file_path,
398        content,
399        structuredPatch: [],
400        originalFile: null,
401        ...(gitDiff && { gitDiff }),
402      }
403  
404      // For creation of new files, count all lines as additions, right before yielding the result
405      countLinesChanged([], content)
406  
407      logFileOperation({
408        operation: 'write',
409        tool: 'FileWriteTool',
410        filePath: fullFilePath,
411        type: 'create',
412      })
413  
414      return {
415        data,
416      }
417    },
418    mapToolResultToToolResultBlockParam({ filePath, type }, toolUseID) {
419      switch (type) {
420        case 'create':
421          return {
422            tool_use_id: toolUseID,
423            type: 'tool_result',
424            content: `File created successfully at: ${filePath}`,
425          }
426        case 'update':
427          return {
428            tool_use_id: toolUseID,
429            type: 'tool_result',
430            content: `The file ${filePath} has been updated successfully.`,
431          }
432      }
433    },
434  } satisfies ToolDef<InputSchema, Output>)