Skip to content

Commit e97123f

Browse files
committed
fix(file): re-check staleness after approval to close TOCTOU window
StrReplaceFile and WriteFile validated staleness before the (unbounded) approval prompt, then wrote the in-memory content wholesale — so an external edit landing during approval was silently clobbered (exact-string matching ran pre-approval and gives no write-time protection). Add a second overwrite_is_stale / _reject_if_stale check immediately after approval, returning the same stale-read error. Regression tests mutate the file during the approval call and assert the external change survives.
1 parent f538708 commit e97123f

4 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/pythinker_code/tools/file/replace.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,20 @@ async def __call__(self, params: Params) -> ToolReturnValue:
495495
if not result:
496496
return result.rejection_error()
497497

498+
# Re-check staleness after approval: the prompt is unbounded user time
499+
# during which the file can change on disk, and the write below replaces
500+
# `content` wholesale (the exact-string match ran against the pre-approval
501+
# read). The first check cannot cover this window; the read-cache is only
502+
# refreshed after the write, so the recorded read-state is still valid.
503+
if await overwrite_is_stale(self._runtime.file_read_cache, p, real_p):
504+
return ToolError(
505+
message=(
506+
"File has been modified since you last read it. Read it again before "
507+
"editing it so you do not clobber the external changes."
508+
),
509+
brief="Stale read",
510+
)
511+
498512
from pythinker_code.soul.toolset import emit_current_tool_execution_started
499513

500514
emit_current_tool_execution_started()

src/pythinker_code/tools/file/write.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,17 @@ async def __call__(self, params: Params) -> ToolReturnValue:
205205
if not result:
206206
return result.rejection_error()
207207

208+
# Re-check staleness after approval: the prompt is unbounded user time
209+
# during which the file can change on disk, and the overwrite below writes
210+
# params.content wholesale. The first check cannot cover this window; the
211+
# read-cache is only refreshed after the write, so the read-state is valid.
212+
if (
213+
file_existed
214+
and params.mode == "overwrite"
215+
and (err := await self._reject_if_stale(p, real_p))
216+
):
217+
return err
218+
208219
from pythinker_code.soul.toolset import emit_current_tool_execution_started
209220

210221
emit_current_tool_execution_started()

tests/tools/test_str_replace_file.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,49 @@ async def test_replace_blocked_when_file_changed_since_read(
561561
assert "external append" in await file_path.read_text() # external change survived
562562

563563

564+
async def test_replace_blocked_when_file_changed_during_approval(
565+
read_file_tool: ReadFile, str_replace_file_tool: StrReplaceFile, temp_work_dir: HostPath
566+
) -> None:
567+
"""Stale-edit guard re-checks AFTER approval: the file can change on disk during the
568+
(unbounded) approval window, and the write replaces content wholesale, so the pre-approval
569+
check alone would clobber the external edit. The post-approval re-check must block it.
570+
571+
The approval `request` is patched to mutate the file (new size + strictly-newer mtime) then
572+
approve — exercising only the second check (the first ran before the prompt, when the file
573+
was still original). The edit's `old` still matches the ORIGINAL content, proving exact
574+
string matching alone does not protect against the external change."""
575+
import os
576+
from unittest.mock import AsyncMock
577+
578+
from pythinker_code.soul.approval import ApprovalResult
579+
580+
file_path = temp_work_dir / "tracked.txt"
581+
await file_path.write_text("keep ME here\n")
582+
assert not (await read_file_tool(ReadParams(path=str(file_path)))).is_error
583+
584+
external_content = "totally different external content\n"
585+
586+
async def mutate_then_approve(tool_name, action, description, **kwargs): # type: ignore[no-untyped-def]
587+
# External change DURING the approval window: different size + strictly-newer mtime.
588+
await file_path.write_text(external_content)
589+
st = os.stat(str(file_path))
590+
os.utime(str(file_path), (st.st_atime, st.st_mtime + 10))
591+
return ApprovalResult(approved=True)
592+
593+
str_replace_file_tool._approval.request = AsyncMock(side_effect=mutate_then_approve) # type: ignore[method-assign]
594+
595+
# `old="ME"` matches the ORIGINAL content (validated pre-approval), so the edit is not
596+
# rejected for a missing string — only the post-approval staleness re-check can block it.
597+
result = await str_replace_file_tool(
598+
Params(path=str(file_path), edit=Edit(old="ME", new="YOU"))
599+
)
600+
assert result.is_error
601+
assert "modified since" in result.message
602+
# The external change survived; the tool's intended edit was NOT applied (no clobber).
603+
assert await file_path.read_text() == external_content
604+
assert "YOU" not in await file_path.read_text()
605+
606+
564607
async def test_replace_allowed_after_read(
565608
read_file_tool: ReadFile, str_replace_file_tool: StrReplaceFile, temp_work_dir: HostPath
566609
) -> None:

tests/tools/test_write_file.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,45 @@ async def test_overwrite_blocked_when_file_changed_since_read(
6969
assert "v2 external change" in await file_path.read_text() # external change survived
7070

7171

72+
async def test_overwrite_blocked_when_file_changed_during_approval(
73+
read_file_tool: ReadFile, write_file_tool: WriteFile, temp_work_dir: HostPath
74+
) -> None:
75+
"""Stale-overwrite guard re-checks AFTER approval: the file can change on disk during the
76+
(unbounded) approval window, and the overwrite writes params.content wholesale, so the
77+
pre-approval check alone would clobber the external edit. The post-approval re-check must
78+
block it.
79+
80+
The approval `request` is patched to mutate the file (new size + strictly-newer mtime) then
81+
approve — exercising only the second check (the first ran before the prompt, when the file
82+
was still original)."""
83+
import os
84+
from unittest.mock import AsyncMock
85+
86+
from pythinker_code.soul.approval import ApprovalResult
87+
88+
file_path = temp_work_dir / "tracked.txt"
89+
await file_path.write_text("v1 content\n")
90+
assert not (await read_file_tool(ReadParams(path=str(file_path)))).is_error
91+
92+
external_content = "v2 external change during approval\n"
93+
94+
async def mutate_then_approve(tool_name, action, description, **kwargs): # type: ignore[no-untyped-def]
95+
# External change DURING the approval window: different size + strictly-newer mtime.
96+
await file_path.write_text(external_content)
97+
st = os.stat(str(file_path))
98+
os.utime(str(file_path), (st.st_atime, st.st_mtime + 10))
99+
return ApprovalResult(approved=True)
100+
101+
write_file_tool._approval.request = AsyncMock(side_effect=mutate_then_approve) # type: ignore[method-assign]
102+
103+
result = await write_file_tool(Params(path=str(file_path), content="v3 agent overwrite\n"))
104+
assert result.is_error
105+
assert "modified since" in result.message
106+
# The external change survived; the agent's overwrite was NOT applied (no clobber).
107+
assert await file_path.read_text() == external_content
108+
assert "v3 agent overwrite" not in await file_path.read_text()
109+
110+
72111
async def test_overwrite_allowed_after_read(
73112
read_file_tool: ReadFile, write_file_tool: WriteFile, temp_work_dir: HostPath
74113
) -> None:

0 commit comments

Comments
 (0)