Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/core/src/tools/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,33 @@ describe('EditTool', () => {
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);
});

it('should return FILE_MODIFIED_DURING_WAIT error if file changed between confirmation and execute', async () => {
fs.writeFileSync(filePath, 'original content', 'utf8');

const params: EditToolParams = {
file_path: filePath,
old_string: 'original',
new_string: 'modified',
};
const invocation = tool.build(params);

// Simulate confirmation flow - this captures mtime
await invocation.shouldConfirmExecute(new AbortController().signal);

// Simulate external modification after confirmation
// Use utimesSync to guarantee a different mtime (works on all filesystems)
fs.writeFileSync(filePath, 'externally modified content', 'utf8');
const futureTime = new Date(Date.now() + 2000);
fs.utimesSync(filePath, futureTime, futureTime);

// Execute should detect the race condition
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.EDIT_FILE_MODIFIED_DURING_WAIT,
);
expect(result.llmContent).toContain('modified externally');
});
});

describe('getDescription', () => {
Expand Down
60 changes: 51 additions & 9 deletions packages/core/src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,16 @@ interface CalculatedEdit {
}

class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
/**
* Stores the file's mtime at the time it was read for confirmation.
* Used to detect if the file was modified externally before we write.
*/
private readMtimeMs?: number;

constructor(
private readonly config: Config,
public params: EditToolParams,
) {}
) { }

toolLocations(): ToolLocation[] {
return [{ path: this.params.file_path }];
Expand Down Expand Up @@ -195,11 +201,11 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {

const newContent = !error
? applyReplacement(
currentContent,
finalOldString,
finalNewString,
isNewFile,
)
currentContent,
finalOldString,
finalNewString,
isNewFile,
)
: (currentContent ?? '');

if (!error && fileExists && currentContent === newContent) {
Expand Down Expand Up @@ -247,6 +253,16 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
return false;
}

// Capture file mtime BEFORE calculateEdit for race condition detection
// This must happen before reading to avoid TOCTOU gap
let capturedMtimeMs: number | undefined;
try {
const stats = await fs.promises.stat(this.params.file_path);
capturedMtimeMs = stats.mtimeMs;
} catch {
// File doesn't exist yet - will be caught in calculateEdit
}

let editData: CalculatedEdit;
try {
editData = await this.calculateEdit(this.params);
Expand All @@ -261,6 +277,11 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
return false;
}

// Only store mtime if file existed (not a new file creation)
if (!editData.isNewFile && capturedMtimeMs !== undefined) {
this.readMtimeMs = capturedMtimeMs;
}

const fileName = path.basename(this.params.file_path);
const fileDiff = Diff.createPatch(
fileName,
Expand All @@ -273,7 +294,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
const ideClient = this.config.getIdeClient();
const ideConfirmation =
this.config.getIdeMode() &&
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
? ideClient.openDiff(this.params.file_path, editData.newContent)
: undefined;

Expand All @@ -297,6 +318,8 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
// for info on a possible race condition where the file is modified on disk while being edited.
this.params.old_string = editData.currentContent ?? '';
this.params.new_string = result.content;
// Clear mtime check since IDE modified params - the file state has intentionally changed
this.readMtimeMs = undefined;
}
}
},
Expand Down Expand Up @@ -333,6 +356,26 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
* @returns Result of the edit operation
*/
async execute(_signal: AbortSignal): Promise<ToolResult> {
// Check for race condition FIRST: file modified since we read it for confirmation
if (this.readMtimeMs !== undefined) {
try {
const currentStats = await fs.promises.stat(this.params.file_path);
if (currentStats.mtimeMs !== this.readMtimeMs) {
const errorMsg = `File was modified externally since it was read. Use ${ReadFileTool.Name} to get current content and retry the edit.`;
return {
llmContent: errorMsg,
returnDisplay: `Error: ${errorMsg}`,
error: {
message: errorMsg,
type: ToolErrorType.EDIT_FILE_MODIFIED_DURING_WAIT,
},
};
}
} catch {
// If we can't stat the file now, let calculateEdit handle the error
}
}

let editData: CalculatedEdit;
try {
editData = await this.calculateEdit(this.params);
Expand Down Expand Up @@ -462,8 +505,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
*/
export class EditTool
extends BaseDeclarativeTool<EditToolParams, ToolResult>
implements ModifiableDeclarativeTool<EditToolParams>
{
implements ModifiableDeclarativeTool<EditToolParams> {
static readonly Name = ToolNames.EDIT;
constructor(private readonly config: Config) {
super(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/tool-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export enum ToolErrorType {
EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found',
EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch',
EDIT_NO_CHANGE = 'edit_no_change',
EDIT_FILE_MODIFIED_DURING_WAIT = 'edit_file_modified_during_wait',

// Glob-specific Errors
GLOB_EXECUTION_ERROR = 'glob_execution_error',
Expand Down