Skip to content

Commit 445a710

Browse files
committed
Preserve file bytes/line endings in edits; harden plan-mode diff guard
Editing tools no longer corrupt files that are not plain LF/UTF-8: - Edit/Insert/diffapply/Write use errors="surrogateescape" + newline="", so invalid UTF-8 bytes (Latin-1, GBK, ...) and the file's own line endings survive the read/write round trip; model-supplied text (LF) is translated to the file's CRLF convention instead of rewriting it - diffapply adopts the file's ending (CRLF/CR/LF) for added lines, so a diff no longer leaves mixed endings behind - Write no longer fails on a non-UTF-8 existing file (UnicodeDecodeError escaped the OSError handler and aborted the write) - recorded diffs are sanitized for display (lone surrogates -> U+FFFD), so the TUI can print diffs of non-UTF-8 files Plan mode: an Edit diff is applied only when every section — old and new path — resolves to the plan file. The macOS/Windows Python applier honors absolute `+++` targets, which bypassed the path-only guard; unparseable diffs now fail closed. Session._plan_diff_verdict reuses the shared _patch_cwd/diff_targets resolution so the guard approves exactly what the backends would write. Tests: regression coverage for all of the above, plus suite hygiene — SESSION_DIR redirects are restored and sandbox temp dirs removed, so a run no longer leaks /tmp dirs or resurrects deleted ones.
1 parent 7f924c0 commit 445a710

15 files changed

Lines changed: 572 additions & 84 deletions

‎python_agent_harness/diffrender.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,39 @@
33
``unified_diff`` builds a standard unified diff between two file
44
contents (used by Edit/Write to record what actually changed).
55
``render_diff`` turns that text into a red/green ``rich`` renderable
6-
suitable for the TUI's tool-output panel.
6+
suitable for the TUI's tool-output panel. ``sanitize_for_display``
7+
makes a recorded diff printable when it carries surrogate-escaped
8+
bytes from a non-UTF-8 file.
79
"""
810

911
from __future__ import annotations
1012

1113
import difflib
14+
import re
1215

1316
from rich.console import Group
1417
from rich.text import Text
1518

1619
MAX_DIFF_LINES = 400 # truncation cap for the rendered (not stored) diff
1720

21+
# Lone surrogates (U+DC80-U+DCFF plus any other surrogate code point):
22+
# what a file read with errors="surrogateescape" produces for bytes that
23+
# are not valid UTF-8.
24+
_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
25+
26+
27+
def sanitize_for_display(text: str) -> str:
28+
"""Replace lone surrogates in TEXT with U+FFFD.
29+
30+
Files are read with ``errors="surrogateescape"`` so invalid bytes
31+
survive a read/write round trip; those bytes appear as lone
32+
surrogates in the string, which cannot be encoded to the terminal
33+
(``print`` would raise ``UnicodeEncodeError``). A recorded diff is
34+
display-only, so the surrogates are replaced here — the file on
35+
disk is never touched.
36+
"""
37+
return _SURROGATE_RE.sub("\ufffd", text)
38+
1839

1940
def unified_diff(
2041
old_content: str,

‎python_agent_harness/session.py‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from . import config
2020
from .client import Client, LLMClient
21+
from .diffrender import sanitize_for_display
2122
from .mcp.config import MCPConfig
2223
from .mcp.manager import MCPManager
2324
from .models import AgentMode
@@ -327,11 +328,16 @@ def execute_tool(
327328
return result
328329

329330
def record_diff(self, diff_text: str) -> None:
330-
"""Attach a unified diff to the tool call currently executing."""
331+
"""Attach a unified diff to the tool call currently executing.
332+
333+
Sanitized for display: file content read with
334+
``errors="surrogateescape"`` (non-UTF-8 files) carries lone
335+
surrogates that a terminal cannot encode.
336+
"""
331337
call_id = getattr(self._active_call, "call_id", None)
332338
if call_id and diff_text:
333339
with self._tool_diffs_lock:
334-
self._tool_diffs[call_id] = diff_text
340+
self._tool_diffs[call_id] = sanitize_for_display(diff_text)
335341

336342
def take_diff(self, call_id: str) -> str | None:
337343
"""Pop and return the diff recorded for CALL_ID, if any."""
@@ -350,23 +356,50 @@ def _plan_blocked(self, name: str, args: dict[str, Any]) -> str | None:
350356
"Error: blocked by plan mode (read-only phase); "
351357
"Bash is disabled — use Read/Glob/Grep for read-only access"
352358
)
359+
if name == "Edit" and args.get("old_str") is None and args.get("diff") is not False:
360+
# diff/patch mode: the `path` argument is NOT enough — the
361+
# patch applies to the paths inside the diff itself, and the
362+
# macOS/Windows Python applier honors absolute `+++` targets.
363+
content = args.get("new_str")
364+
if isinstance(content, str) and content.strip():
365+
return self._plan_diff_verdict(args)
366+
# contentless diff call: Edit rejects a missing/invalid
367+
# new_str before any patch engine runs, so the plain path
368+
# check below is sufficient
353369
path = self._tool_path(name, args)
354370
if path and path != self.plan_mode.plan_file:
355-
# Edit diff-mode (no old_str, diff not explicitly False) runs
356-
# `patch` which can write to arbitrary files via relative paths
357-
# in the diff content — block it even if the target path looks
358-
# innocent, because patch follows paths within the diff.
359-
if name == "Edit" and args.get("old_str") is None and args.get("diff") is not False:
360-
return (
361-
"Error: blocked by plan mode (read-only phase); "
362-
"diff/patch mode cannot target files other than the plan "
363-
"file — use string replacement (old_str/new_str) instead"
364-
)
365371
return (
366372
"Error: blocked by plan mode (read-only phase); only the plan file may be modified"
367373
)
368374
return None
369375

376+
def _plan_diff_verdict(self, args: dict[str, Any]) -> str | None:
377+
"""Allow an Edit diff only when every section targets the plan file.
378+
379+
Resolution mirrors the Edit backends (same cwd/fallback rules,
380+
same helpers as the built-in Python applier), so the sections
381+
approved here are exactly the files the backend would write. A
382+
diff that cannot be parsed into file sections is refused (fail
383+
closed): an unverifiable patch must never reach a patch engine.
384+
"""
385+
from .tools.diffapply import diff_targets
386+
from .tools.edit import _patch_cwd, _strip_diff_fence
387+
388+
plan_file = self.plan_mode.plan_file
389+
raw = str(args.get("path", ""))
390+
path = os.path.realpath(os.path.abspath(raw))
391+
cwd = _patch_cwd(raw, path)
392+
text = str(args.get("new_str"))
393+
text = text if text.endswith("\n") else text + "\n"
394+
targets = diff_targets(_strip_diff_fence(text), cwd, path if os.path.isfile(path) else None)
395+
if plan_file and targets is not None and all(target == plan_file for target in targets):
396+
return None
397+
return (
398+
"Error: blocked by plan mode (read-only phase); "
399+
"diff/patch mode cannot target files other than the plan "
400+
"file — use string replacement (old_str/new_str) instead"
401+
)
402+
370403
def _tool_path(self, name: str, args: dict[str, Any]) -> str | None:
371404
if name == "Write":
372405
return os.path.realpath(

‎python_agent_harness/tools/diffapply.py‎

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ def old_seq(self) -> list[tuple[str, str]]:
5353

5454

5555
class _Section:
56-
__slots__ = ("new_path", "hunks")
56+
__slots__ = ("old_path", "new_path", "hunks")
5757

58-
def __init__(self, new_path: str, hunks: list[_Hunk]) -> None:
58+
def __init__(self, old_path: str, new_path: str, hunks: list[_Hunk]) -> None:
59+
self.old_path = old_path
5960
self.new_path = new_path
6061
self.hunks = hunks
6162

@@ -82,6 +83,8 @@ def _parse(diff_text: str) -> list[_Section]:
8283
i += 1
8384
continue
8485
new_path = lines[i + 1][len("+++") :].strip()
86+
# git-style "a/... b/..." header, old path on the `---` line
87+
old_path = lines[i][len("---") :].strip()
8588
i += 2
8689
hunks: list[_Hunk] = []
8790
while i < n:
@@ -108,7 +111,7 @@ def _parse(diff_text: str) -> list[_Section]:
108111
if _section_header_at(lines, i):
109112
break
110113
i += 1 # stray line (e.g. "diff --git", "index ...") — skip
111-
sections.append(_Section(new_path, hunks))
114+
sections.append(_Section(old_path, new_path, hunks))
112115
return sections
113116

114117

@@ -157,8 +160,36 @@ def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
157160
return None
158161

159162

160-
def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str]) -> None:
161-
"""Replace the matched region with the hunk's new lines in place."""
163+
def _file_line_ending(file_lines: list[str]) -> str:
164+
"""The file's line-ending convention: ``"\\r\\n"``, ``"\\r"`` or ``"\\n"``.
165+
166+
Decided by the first ``"\\n"``-terminated line, so a classic-Mac
167+
``"\\r"`` terminator (or a stray carriage return, which universal
168+
newline reading treats as a terminator) never flips a normal file's
169+
convention. ``"\\r"`` is returned only when the file has no
170+
``"\\n"`` terminator at all. Files without any terminator (a
171+
single unterminated line) default to ``"\\n"``.
172+
"""
173+
saw_cr_only = False
174+
for line in file_lines:
175+
if line.endswith("\r\n"):
176+
return "\r\n"
177+
if line.endswith("\n"):
178+
return "\n"
179+
if line.endswith("\r"):
180+
saw_cr_only = True
181+
return "\r" if saw_cr_only else "\n"
182+
183+
184+
def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str], ending: str) -> None:
185+
"""Replace the matched region with the hunk's new lines in place.
186+
187+
Context lines keep the file's original bytes; added lines adopt the
188+
file's line-ending convention (*ending*) — the diff's own ``+``
189+
lines usually carry LF (the model / git), and letting them through
190+
verbatim would leave a CRLF file with mixed endings. A line marked
191+
``\\ No newline at end of file`` gets no terminator at all.
192+
"""
162193
old_count = sum(1 for kind, _, _ in hunk.body if kind in (" ", "-"))
163194
new_block: list[str] = []
164195
p = pos
@@ -169,12 +200,8 @@ def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str]) -> None:
169200
elif kind == "-":
170201
p += 1
171202
else: # "+"
172-
if no_newline:
173-
new_block.append(content.rstrip("\n"))
174-
elif content.endswith("\n"):
175-
new_block.append(content)
176-
else:
177-
new_block.append(content + "\n")
203+
body = content.rstrip("\r\n")
204+
new_block.append(body if no_newline else body + ending)
178205
file_lines[pos : pos + old_count] = new_block
179206

180207

@@ -199,8 +226,12 @@ def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tu
199226
target = _resolve_target(section.new_path, cwd, fallback_path)
200227
if not os.path.isfile(target):
201228
return False, f"target file does not exist: {target}"
229+
# surrogateescape + newline="": invalid UTF-8 bytes and the file's own
230+
# line endings survive untouched for every line the diff does not
231+
# change (errors="replace" used to persist U+FFFD for such bytes, and
232+
# universal newlines rewrote the whole file's endings).
202233
try:
203-
with open(target, encoding="utf-8", errors="replace") as f:
234+
with open(target, encoding="utf-8", errors="surrogateescape", newline="") as f:
204235
file_lines = f.readlines()
205236
except OSError as e:
206237
return False, f"cannot read {target}: {e}"
@@ -216,10 +247,11 @@ def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tu
216247
)
217248
plan.append((hunk, pos))
218249
new_lines = list(file_lines)
250+
ending = _file_line_ending(file_lines)
219251
for hunk, pos in reversed(plan): # bottom-up: earlier positions stay valid
220-
_apply_hunk(hunk, pos, new_lines)
252+
_apply_hunk(hunk, pos, new_lines, ending)
221253
try:
222-
with open(target, "w", encoding="utf-8") as f:
254+
with open(target, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
223255
f.writelines(new_lines)
224256
except OSError as e:
225257
return False, f"cannot write {target}: {e}"
@@ -244,3 +276,27 @@ def apply_unified_diff(
244276
if not ok:
245277
return False, msg
246278
return True, f"applied {len(sections)} file section(s)"
279+
280+
281+
def diff_targets(diff_text: str, cwd: str, fallback_path: str | None = None) -> list[str] | None:
282+
"""Paths a patch could plausibly touch for this diff.
283+
284+
Includes every section's ``---`` (old) AND ``+++`` (new) path,
285+
resolved exactly like the applier resolves the new path. The
286+
built-in applier only ever writes the new path, but external patch
287+
engines (GNU patch) may consult the old path too, so a caller
288+
gating writes must treat both as in scope. Returns ``None`` when
289+
the text yields no file section — the caller decides whether an
290+
unverifiable diff is acceptable (the plan-mode guard treats it as a
291+
refusal).
292+
"""
293+
sections = _parse(diff_text)
294+
if not sections:
295+
return None
296+
targets: list[str] = []
297+
for section in sections:
298+
for path in (section.old_path, section.new_path):
299+
resolved = os.path.realpath(_resolve_target(path, cwd, fallback_path))
300+
if resolved not in targets:
301+
targets.append(resolved)
302+
return targets

‎python_agent_harness/tools/edit.py‎

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,41 @@
1818
from ..diffrender import unified_diff
1919
from .base import Tool, ToolContext
2020

21+
# Any line ending, as a single compiled pattern (CRLF first so a CRLF is
22+
# never rewritten as two endings).
23+
_NEWLINE_RE = re.compile(r"\r\n|\r|\n")
24+
25+
26+
def _uses_crlf(text: str) -> bool:
27+
"""Whether the FIRST line ending in TEXT is CRLF.
28+
29+
Files are read with ``newline=""`` so their endings are preserved
30+
byte-for-byte; this detector decides how model-provided text (which
31+
is almost always LF) must be translated to match the file's own
32+
convention instead of silently rewriting it.
33+
"""
34+
idx = text.find("\n")
35+
return idx > 0 and text[idx - 1] == "\r"
36+
37+
38+
def _to_crlf(text: str) -> str:
39+
"""Rewrite every line ending in TEXT to CRLF."""
40+
return _NEWLINE_RE.sub("\r\n", text)
41+
42+
43+
def _patch_cwd(raw: str, path: str) -> str:
44+
"""The working directory a diff applies in (Emacs `file-name-directory`).
45+
46+
A trailing-slash directory path -> that directory itself, so a
47+
multi-file diff applies to files within it; otherwise the file's
48+
parent. Shared by `Edit.run` and the plan-mode diff guard
49+
(``Session._plan_diff_verdict``) so both always agree on where a
50+
patch's section paths resolve.
51+
"""
52+
if raw.endswith("/") or raw.endswith(os.sep):
53+
return path or "/"
54+
return os.path.dirname(path) or "/"
55+
2156

2257
class Edit(Tool):
2358
name = "Edit"
@@ -73,13 +108,11 @@ def run(self, args: dict, ctx: ToolContext) -> str:
73108
# gptel: string mode when `diff` is false OR `old_str` is provided.
74109
if diffp is False or old is not None:
75110
return self._string_replace(path, old, new_str, ctx)
76-
# Diff mode runs `patch` in Emacs `file-name-directory' of the path:
77-
# a trailing-slash directory path -> that directory itself, so a
78-
# multi-file diff applies to files within it; otherwise the parent.
79-
if raw.endswith("/") or raw.endswith(os.sep):
80-
cwd = path or "/"
81-
else:
82-
cwd = os.path.dirname(path) or "/"
111+
# Diff mode runs `patch` in Emacs `file-name-directory' of the path
112+
# (see _patch_cwd): a trailing-slash directory path -> that
113+
# directory itself, so a multi-file diff applies to files within
114+
# it; otherwise the parent.
115+
cwd = _patch_cwd(raw, path)
83116
return self._apply_patch(path, cwd, new_str, ctx)
84117

85118
def _string_replace(self, path: str, old: str | None, new_str: str, ctx: ToolContext) -> str:
@@ -89,11 +122,23 @@ def _string_replace(self, path: str, old: str | None, new_str: str, ctx: ToolCon
89122
)
90123
if old is None:
91124
return "Error: old_str is required for non-diff edits"
125+
if not isinstance(old, str) or not isinstance(new_str, str):
126+
return "Error: old_str and new_str must be strings"
127+
# surrogateescape: invalid UTF-8 bytes (Latin-1, GBK, ...) survive
128+
# the read/rewrite round trip — errors="replace" would silently
129+
# persist U+FFFD for every such byte. newline="": the file's own
130+
# line endings are preserved instead of being rewritten to LF.
92131
try:
93-
with open(path, encoding="utf-8", errors="replace") as f:
132+
with open(path, encoding="utf-8", errors="surrogateescape", newline="") as f:
94133
content = f.read()
95134
except OSError as e:
96135
return f"Error: cannot read {path}: {e}"
136+
if _uses_crlf(content):
137+
# the file uses CRLF; the model's old_str/new_str are nearly
138+
# always LF, so translate them to the file's convention —
139+
# the match still works and the edit never mixes endings
140+
old = _to_crlf(old)
141+
new_str = _to_crlf(new_str)
97142
count = content.count(old)
98143
if count == 0:
99144
return f'Error: Could not find old_str "{old[:20]}" in file {path}'
@@ -104,7 +149,7 @@ def _string_replace(self, path: str, old: str | None, new_str: str, ctx: ToolCon
104149
)
105150
new = content.replace(old, new_str, 1)
106151
try:
107-
with open(path, "w", encoding="utf-8") as f:
152+
with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
108153
f.write(new)
109154
except OSError as e:
110155
return f"Error: {e}"

0 commit comments

Comments
 (0)