Skip to content

Commit 54ed837

Browse files
agn-7claude
andcommitted
fix(filesystem): address review feedback on append/write_or_update tools
- Use fs.appendFile directly instead of read-modify-write (O(1) memory) - Eliminate TOCTOU race between fs.access and fs.readFile - Replace unreliable birthtime===mtime heuristic with pre-op existence check - Have writeOrUpdateFileContent delegate to appendFileContent (no duplication) - Consolidate three identical Zod schemas into one PathContentArgsSchema Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad04e2e commit 54ed837

3 files changed

Lines changed: 36 additions & 98 deletions

File tree

src/filesystem/__tests__/lib.test.ts

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -316,48 +316,39 @@ describe('Lib Functions', () => {
316316
it('throws error if file does not exist', async () => {
317317
const error = new Error('ENOENT');
318318
(error as any).code = 'ENOENT';
319-
mockFs.access.mockRejectedValue(error);
319+
mockFs.appendFile.mockRejectedValue(error);
320320

321321
await expect(appendFileContent('/test/nonexistent.txt', 'new content'))
322322
.rejects.toThrow('File does not exist');
323323
});
324324

325325
it('appends content to existing file', async () => {
326-
mockFs.access.mockResolvedValue(undefined);
327-
mockFs.readFile.mockResolvedValue('existing content' as any);
328-
mockFs.writeFile.mockResolvedValue(undefined);
329-
mockFs.rename.mockResolvedValue(undefined);
326+
mockFs.appendFile.mockResolvedValue(undefined);
330327

331328
await appendFileContent('/test/file.txt', '\nnew content');
332329

333-
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
334-
expect(mockFs.writeFile).toHaveBeenCalledWith(
335-
expect.stringContaining('.tmp'),
336-
'existing content\nnew content',
337-
'utf-8'
330+
expect(mockFs.appendFile).toHaveBeenCalledWith(
331+
'/test/file.txt',
332+
'\nnew content',
333+
{ encoding: 'utf-8', flag: 'a' }
338334
);
339-
expect(mockFs.rename).toHaveBeenCalled();
340335
});
341336

342-
it('handles write errors and cleans up temp file', async () => {
343-
mockFs.access.mockResolvedValue(undefined);
344-
mockFs.readFile.mockResolvedValue('existing content' as any);
345-
mockFs.writeFile.mockResolvedValue(undefined);
346-
mockFs.rename.mockRejectedValue(new Error('Rename failed'));
347-
mockFs.unlink.mockResolvedValue(undefined);
337+
it('propagates non-ENOENT errors', async () => {
338+
const error = new Error('Permission denied');
339+
(error as any).code = 'EACCES';
340+
mockFs.appendFile.mockRejectedValue(error);
348341

349342
await expect(appendFileContent('/test/file.txt', 'new content'))
350-
.rejects.toThrow('Rename failed');
351-
352-
expect(mockFs.unlink).toHaveBeenCalled();
343+
.rejects.toThrow('Permission denied');
353344
});
354345
});
355346

356347
describe('writeOrUpdateFileContent', () => {
357348
it('creates new file if it does not exist', async () => {
358349
const error = new Error('ENOENT');
359350
(error as any).code = 'ENOENT';
360-
mockFs.access.mockRejectedValue(error);
351+
mockFs.appendFile.mockRejectedValue(error);
361352
mockFs.writeFile.mockResolvedValue(undefined);
362353

363354
await writeOrUpdateFileContent('/test/newfile.txt', 'initial content');
@@ -370,29 +361,25 @@ describe('Lib Functions', () => {
370361
});
371362

372363
it('appends to existing file', async () => {
373-
mockFs.access.mockResolvedValue(undefined);
374-
mockFs.readFile.mockResolvedValue('existing content' as any);
375-
mockFs.writeFile.mockResolvedValue(undefined);
376-
mockFs.rename.mockResolvedValue(undefined);
364+
mockFs.appendFile.mockResolvedValue(undefined);
377365

378366
await writeOrUpdateFileContent('/test/file.txt', '\nappended content');
379367

380-
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
381-
expect(mockFs.writeFile).toHaveBeenCalledWith(
382-
expect.stringContaining('.tmp'),
383-
'existing content\nappended content',
384-
'utf-8'
368+
expect(mockFs.appendFile).toHaveBeenCalledWith(
369+
'/test/file.txt',
370+
'\nappended content',
371+
{ encoding: 'utf-8', flag: 'a' }
385372
);
386-
expect(mockFs.rename).toHaveBeenCalled();
373+
expect(mockFs.writeFile).not.toHaveBeenCalled();
387374
});
388375

389-
it('handles file exists error during creation by using atomic write', async () => {
376+
it('falls back to atomic rename when target file already exists at create time', async () => {
390377
const notFoundError = new Error('ENOENT');
391378
(notFoundError as any).code = 'ENOENT';
392379
const existsError = new Error('EEXIST');
393380
(existsError as any).code = 'EEXIST';
394381

395-
mockFs.access.mockRejectedValue(notFoundError);
382+
mockFs.appendFile.mockRejectedValue(notFoundError);
396383
mockFs.writeFile
397384
.mockRejectedValueOnce(existsError)
398385
.mockResolvedValueOnce(undefined);
@@ -404,10 +391,10 @@ describe('Lib Functions', () => {
404391
expect(mockFs.rename).toHaveBeenCalled();
405392
});
406393

407-
it('propagates non-ENOENT access errors', async () => {
394+
it('propagates non-ENOENT append errors', async () => {
408395
const error = new Error('Permission denied');
409396
(error as any).code = 'EACCES';
410-
mockFs.access.mockRejectedValue(error);
397+
mockFs.appendFile.mockRejectedValue(error);
411398

412399
await expect(writeOrUpdateFileContent('/test/file.txt', 'content'))
413400
.rejects.toThrow('Permission denied');

src/filesystem/index.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,13 @@ const ReadMultipleFilesArgsSchema = z.object({
112112
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."),
113113
});
114114

115-
const WriteFileArgsSchema = z.object({
116-
path: z.string(),
117-
content: z.string(),
118-
});
119-
120-
const AppendFileArgsSchema = z.object({
121-
path: z.string(),
122-
content: z.string(),
123-
});
124-
125-
const WriteOrUpdateFileArgsSchema = z.object({
115+
const PathContentArgsSchema = z.object({
126116
path: z.string(),
127117
content: z.string(),
128118
});
119+
const WriteFileArgsSchema = PathContentArgsSchema;
120+
const AppendFileArgsSchema = PathContentArgsSchema;
121+
const WriteOrUpdateFileArgsSchema = PathContentArgsSchema;
129122

130123
const EditOperation = z.object({
131124
oldText: z.string().describe('Text to search for - must match exactly'),
@@ -420,13 +413,12 @@ server.registerTool(
420413
},
421414
async (args: z.infer<typeof WriteOrUpdateFileArgsSchema>) => {
422415
const validPath = await validatePath(args.path);
416+
const existed = await fs.access(validPath).then(() => true).catch(() => false);
423417
await writeOrUpdateFileContent(validPath, args.content);
424418

425-
// Determine if file was created or updated for better feedback
426-
const stats = await fs.stat(validPath);
427-
const message = stats.birthtime.getTime() === stats.mtime.getTime()
428-
? `Successfully created ${args.path} with content`
429-
: `Successfully appended content to ${args.path}`;
419+
const message = existed
420+
? `Successfully appended content to ${args.path}`
421+
: `Successfully created ${args.path} with content`;
430422

431423
return {
432424
content: [{ type: "text" as const, text: message }],

src/filesystem/lib.ts

Lines changed: 6 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -185,66 +185,25 @@ export async function writeFileContent(filePath: string, content: string): Promi
185185
}
186186

187187
export async function appendFileContent(filePath: string, content: string): Promise<void> {
188-
// Check if file exists
189188
try {
190-
await fs.access(filePath);
189+
await fs.appendFile(filePath, content, { encoding: 'utf-8', flag: 'a' });
191190
} catch (error) {
192191
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
193192
throw new Error(`File does not exist: ${filePath}`);
194193
}
195194
throw error;
196195
}
197-
198-
// Read existing content
199-
const existingContent = await readFileContent(filePath);
200-
201-
// Combine existing and new content
202-
const combinedContent = existingContent + content;
203-
204-
// Use atomic write to update the file
205-
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
206-
try {
207-
await fs.writeFile(tempPath, combinedContent, 'utf-8');
208-
await fs.rename(tempPath, filePath);
209-
} catch (error) {
210-
try {
211-
await fs.unlink(tempPath);
212-
} catch {}
213-
throw error;
214-
}
215196
}
216197

217198
export async function writeOrUpdateFileContent(filePath: string, content: string): Promise<void> {
218-
// Check if file exists
219-
let fileExists = false;
220199
try {
221-
await fs.access(filePath);
222-
fileExists = true;
200+
await appendFileContent(filePath, content);
223201
} catch (error) {
224-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
225-
throw error;
226-
}
227-
}
228-
229-
if (fileExists) {
230-
// File exists, append the content
231-
const existingContent = await readFileContent(filePath);
232-
const combinedContent = existingContent + content;
233-
234-
// Use atomic write to update the file
235-
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
236-
try {
237-
await fs.writeFile(tempPath, combinedContent, 'utf-8');
238-
await fs.rename(tempPath, filePath);
239-
} catch (error) {
240-
try {
241-
await fs.unlink(tempPath);
242-
} catch {}
243-
throw error;
202+
if (error instanceof Error && error.message.startsWith('File does not exist:')) {
203+
await writeFileContent(filePath, content);
204+
return;
244205
}
245-
} else {
246-
// File doesn't exist, create it with the content
247-
await writeFileContent(filePath, content);
206+
throw error;
248207
}
249208
}
250209

0 commit comments

Comments
 (0)