source dump of claude code
at main 434 lines 15 kB view raw
1import { dirname, sep } from 'path' 2import { logEvent } from 'src/services/analytics/index.js' 3import { z } from 'zod/v4' 4import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5import { diagnosticTracker } from '../../services/diagnosticTracking.js' 6import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js' 7import { getLspServerManager } from '../../services/lsp/manager.js' 8import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js' 9import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js' 10import { 11 activateConditionalSkillsForPaths, 12 addSkillDirectories, 13 discoverSkillDirsForPaths, 14} from '../../skills/loadSkillsDir.js' 15import type { ToolUseContext } from '../../Tool.js' 16import { buildTool, type ToolDef } from '../../Tool.js' 17import { getCwd } from '../../utils/cwd.js' 18import { logForDebugging } from '../../utils/debug.js' 19import { countLinesChanged, getPatchForDisplay } from '../../utils/diff.js' 20import { isEnvTruthy } from '../../utils/envUtils.js' 21import { isENOENT } from '../../utils/errors.js' 22import { getFileModificationTime, writeTextContent } from '../../utils/file.js' 23import { 24 fileHistoryEnabled, 25 fileHistoryTrackEdit, 26} from '../../utils/fileHistory.js' 27import { logFileOperation } from '../../utils/fileOperationAnalytics.js' 28import { readFileSyncWithMetadata } from '../../utils/fileRead.js' 29import { getFsImplementation } from '../../utils/fsOperations.js' 30import { 31 fetchSingleFileGitDiff, 32 type ToolUseDiff, 33} from '../../utils/gitDiff.js' 34import { lazySchema } from '../../utils/lazySchema.js' 35import { logError } from '../../utils/log.js' 36import { expandPath } from '../../utils/path.js' 37import { 38 checkWritePermissionForTool, 39 matchingRuleForInput, 40} from '../../utils/permissions/filesystem.js' 41import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 42import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 43import { FILE_UNEXPECTEDLY_MODIFIED_ERROR } from '../FileEditTool/constants.js' 44import { gitDiffSchema, hunkSchema } from '../FileEditTool/types.js' 45import { FILE_WRITE_TOOL_NAME, getWriteToolDescription } from './prompt.js' 46import { 47 getToolUseSummary, 48 isResultTruncated, 49 renderToolResultMessage, 50 renderToolUseErrorMessage, 51 renderToolUseMessage, 52 renderToolUseRejectedMessage, 53 userFacingName, 54} from './UI.js' 55 56const 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) 66type InputSchema = ReturnType<typeof inputSchema> 67 68const 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) 89type OutputSchema = ReturnType<typeof outputSchema> 90 91export type Output = z.infer<OutputSchema> 92export type FileWriteToolInput = InputSchema 93 94export 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>)