Files
hermes-agent/tools/file_operations.py
brooklyn! 1a72042115 fix(docker): bind a Windows workspace when /workspace is already claimed
A volume that already owns /workspace skipped the configured working
directory, so tools treated that host path as unmounted. Bind it at a
second mount, or point tools at the volume that already has it, for any
drive path.
2026-09-26 18:27:10 -05:00

1704 lines
91 KiB
Python

#!/usr/bin/env python3
"""File operations (read, write, patch, search) over any terminal backend.
Every operation is a shell command run through the backend's ``execute()``, so one
implementation serves every environment (local, docker, ssh, modal, ...). Companions:
``file_operations_common`` (result dataclasses, text helpers), ``file_operations_lint``
(LintMixin), ``file_operations_search`` (SearchMixin).
"""
import base64
import binascii
import os
import re
import sys
import difflib
import hashlib
import json
import logging
import secrets
import unicodedata
from abc import ABC, abstractmethod
from typing import Optional, Dict
from pathlib import Path
from tools.binary_extensions import has_binary_extension
from agent.file_safety import get_write_denied_error
from tools.file_operations_common import (
ExecuteResult, PatchResult, ReadResult, SearchResult, WriteResult,
_UTF8_BOM, _detect_line_ending, _has_bom, _normalize_line_endings, _strip_bom,
_strip_terminal_fence_leaks, normalize_read_pagination, normalize_search_pagination)
from tools.file_operations_lint import LINTERS_INPROC, LintMixin, _FAIL_CLOSED_INPROC_EXTS
from tools.file_operations_search import SearchMixin
logger = logging.getLogger(__name__)
# Controller home; SearchMixin reads it (tests monkeypatch it here).
_HOME = str(Path.home())
# --- Binary-content identification -------------------------------------------
_MAGIC_SIGNATURES: tuple = (
# (prefix bytes, human name) — ordered, first match wins. Longest
# prefixes for a shared first byte come first.
(b"\x89PNG\r\n\x1a\n", "PNG image data"),
(b"\xff\xd8\xff", "JPEG image data"),
(b"GIF87a", "GIF image data"),
(b"GIF89a", "GIF image data"),
(b"RIFF", "RIFF container (WAV/AVI/WebP family)"),
(b"%PDF-", "PDF document"),
(b"PK\x03\x04", "ZIP archive (also docx/xlsx/jar/apk)"),
(b"PK\x05\x06", "ZIP archive (empty)"),
(b"\x1f\x8b", "gzip compressed data"),
(b"BZh", "bzip2 compressed data"),
(b"\xfd7zXZ\x00", "xz compressed data"),
(b"7z\xbc\xaf\x27\x1c", "7-Zip archive"),
(b"\x7fELF", "ELF executable"),
(b"MZ", "Windows PE executable"),
(b"\xcf\xfa\xed\xfe", "Mach-O executable (64-bit)"),
(b"\xca\xfe\xba\xbe", "Mach-O universal binary / Java class"),
(b"SQLite format 3\x00", "SQLite database"),
(b"OggS", "Ogg container"),
(b"fLaC", "FLAC audio"),
(b"ID3", "MP3 audio (ID3 tag)"),
(b"\x00\x00\x00", "ISO media container (MP4/MOV family)"), # ftyp at +4
(b"BM", "BMP image data"),
(b"II*\x00", "TIFF image data (little-endian)"),
(b"MM\x00*", "TIFF image data (big-endian)"),
)
def identify_binary_bytes(sample: bytes) -> str:
"""Best-effort human name for binary content from its magic bytes; never raises.
The ISO-media entry additionally requires ``ftyp`` at offset 4 (three leading
NULs alone are too weak a signature)."""
for prefix, name in _MAGIC_SIGNATURES:
if sample.startswith(prefix):
if name.startswith("ISO media") and sample[4:8] != b"ftyp":
continue
return name
return "unknown binary"
def describe_binary_file(sample: Optional[bytes], file_size: int) -> str:
"""One-line binary-file refusal naming the TYPE ("PNG image data, 4.1 KB"), so the
model gets what-is-this in one read instead of hunting for tools it may lack."""
kind = identify_binary_bytes(sample or b"")
if file_size >= 1024 * 1024:
size = f"{file_size / (1024 * 1024):.1f} MB"
elif file_size >= 1024:
size = f"{file_size / 1024:.1f} KB"
else:
size = f"{file_size} bytes"
return f"Binary file ({kind}, {size}) — cannot display as text."
class FileOperations(ABC):
"""Abstract interface for file operations across terminal backends."""
@abstractmethod
def read_file(self, path: str, offset: int = 1, limit: int = 2000) -> ReadResult:
"""Read a file with pagination support."""
@abstractmethod
def read_file_raw(self, path: str) -> ReadResult:
"""Whole file as a plain string: no pagination, line numbers or clamping."""
@abstractmethod
def write_file(self, path: str, content: str, pre_content: Optional[str] = None) -> WriteResult:
"""Write content to a file, creating directories as needed."""
@abstractmethod
def patch_replace(self, path: str, old_string: str, new_string: str,
replace_all: bool = False) -> PatchResult:
"""Replace text in a file using fuzzy matching."""
@abstractmethod
def patch_v4a(self, patch_content: str) -> PatchResult:
"""Apply a V4A format patch."""
@abstractmethod
def delete_file(self, path: str) -> WriteResult:
"""Delete a file. Returns WriteResult with .error set on failure."""
@abstractmethod
def move_file(self, src: str, dst: str) -> WriteResult:
"""Move/rename a file. Returns WriteResult with .error set on failure."""
@abstractmethod
def search(self, pattern: str, path: str = ".", target: str = "content",
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
order: str = "discovery") -> SearchResult:
"""Search for content or files."""
# --- Shell-based implementation ----------------------------------------------
# Image extensions (subset of binary that we can return as base64)
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'}
# Echoed by the size probe when the path exists but is not a regular file.
# `wc -c` prints only digits, so this can never collide with a real size.
NOT_REGULAR_SENTINEL = "__hermes_not_regular__"
# Echoed by the compound read/write probes when the path does not exist. A
# compound command only reports its *last* exit status, so the missing-file
# signal that ``_probe_regular_file`` carries in ``exit 1`` travels in-band.
MISSING_SENTINEL = "__hermes_missing__"
_READ_SENTINEL_PREFIX = "__HERMES_RF_"
_WRITE_SENTINEL_PREFIX = "__HERMES_WF_"
_BYTES_SENTINEL_PREFIX = "__HERMES_RB_"
def _new_sentinel(prefix: str) -> str:
"""Per-call separator line for a compound shell probe. 128 random bits make a
collision with file content negligible; the underscores keep the token outside
the base64 alphabet, so a sentinel leaking into a sample segment fails base64
validation instead of decoding into bytes."""
return f"{prefix}{secrets.token_hex(16)}__"
def _split_segments(output: str, sentinel: str) -> list[str]:
"""Split compound-probe stdout on its sentinel lines. Every producer (``wc``,
``base64``, ``cut``) newline-terminates or prints nothing, so the separator is
always ``sentinel + "\n"`` on its own line; the text after the final sentinel
is the status segment."""
return output.split(sentinel + "\n")
class ShellFileOperations(LintMixin, SearchMixin, FileOperations):
"""File operations over any terminal backend exposing ``execute(command, cwd)``
returning ``{"output": str, "returncode": int}``.
cwd rule: every ``_exec`` prefers the LIVE ``env.cwd`` so a ``cd`` run via the
terminal tool is picked up immediately; the init-time ``self.cwd`` is only a
fallback for envs that don't track cwd (using it for every call once made
patches "succeed" with a plausible diff while landing in the wrong directory).
"""
def __init__(self, terminal_env, cwd: str = None):
self.env = terminal_env
# Never os.getcwd(): that is the HOST path, absent inside container backends.
self.cwd = cwd or getattr(terminal_env, 'cwd', None) or \
getattr(getattr(terminal_env, 'config', None), 'cwd', None) or "/"
# Ordinary executables: bool cache (hits AND misses). rg is special — it has
# an off-PATH resolver and may be installed mid-session — so only successful
# rg resolutions are cached (see SearchMixin._resolve_command).
self._command_cache: Dict[str, bool] = {}
self._rg_resolution_cache: Dict[str, str] = {}
self._rg_modified_capability: Dict[str, Optional[str]] = {}
def _exec(self, command: str, cwd: str = None, timeout: int = None,
stdin_data: str = None) -> ExecuteResult:
"""Run ``command`` on the backend. cwd: explicit arg → live ``env.cwd`` →
init-time ``self.cwd``. ``stdin_data`` is piped (bypasses ARG_MAX)."""
kwargs = {}
if timeout:
kwargs['timeout'] = timeout
if stdin_data is not None:
kwargs['stdin_data'] = stdin_data
effective_cwd = cwd or getattr(self.env, 'cwd', None) or self.cwd
result = self.env.execute(command, cwd=effective_cwd, **kwargs)
exit_code = result.get("returncode", 0)
output = result.get("output", "")
# The command wrapper's own ``builtin cd -- <cwd> || exit 126`` failed: the
# working directory does not exist on this backend (typically ``terminal.cwd``
# is a host path and the backend is a container). Name that, or the raw
# ``cd:`` line reads like a sandbox/mount fault at the requested path.
cwd_error = ""
if exit_code == 126 and "cd: " in output:
from tools.terminal_tool_config import _is_container_backend
env_type = getattr(self.env, "env_type", None)
hint = ("; for container backends use a path inside the container, e.g. /workspace"
if env_type and _is_container_backend(env_type) else "")
cwd_error = output = (
f"working directory {effective_cwd!r} does not exist on the active terminal "
f"backend (check terminal.cwd or the session cwd{hint}). {output.strip()}")
# A stdin write failure with a clean child exit is still a failure: the
# child never received the input.
if result.get("stdin_error") and exit_code == 0:
exit_code = 1
return ExecuteResult(stdout=output, exit_code=exit_code, cwd_error=cwd_error)
def _has_command(self, cmd: str) -> bool:
"""Check if a command exists in the environment (cached); rg goes through
the resolver so a mid-session install becomes visible."""
if cmd == "rg":
return self._resolve_command(cmd) is not None
if cmd not in self._command_cache:
result = self._exec(f"command -v {cmd} >/dev/null 2>&1 && echo 'yes'")
if result.cwd_error: # the probe never ran: no verdict to cache
return False
self._command_cache[cmd] = result.stdout.strip() == 'yes'
return self._command_cache[cmd]
def _cat(self, path: str) -> ExecuteResult:
"""``cat`` the file with stderr silenced (missing file → non-zero exit)."""
return self._exec(f"cat {self._escape_shell_arg(path)} 2>/dev/null")
def _head(self, path: str, nbytes: int) -> ExecuteResult:
return self._exec(f"head -c {nbytes} {self._escape_shell_arg(path)} 2>/dev/null")
def _run_python_snippet(self, snippet: str) -> ExecuteResult:
"""Run ``snippet`` via the backend's ``python3``, retrying with ``python``
when only that name exists (Windows / older systems)."""
result = self._exec(f"python3 -c {self._escape_shell_arg(snippet)}")
if result.exit_code != 0 and "python3" in (result.stdout or ""):
result = self._exec(f"python -c {self._escape_shell_arg(snippet)}")
return result
def _fenced_read(self, body: str, *more: str) -> "tuple[Optional[list[str]], Optional[int], ExecuteResult]":
"""Run BODY, then each of MORE, each in its own sentinel-delimited segment; return (those
segments, BODY's exit status, reply).
The transport merges the backend's own stdout with the command's, and every caller here
decodes a segment into file bytes, so the payload has to be delimited rather than taken to
be the whole reply: a remote shell announcing ``TERM`` is four base64 characters that would
otherwise join the payload and decode to ``b"LDL"`` at the head of it. The fence drops noise
OUTSIDE it only; output emitted while BODY runs (a ``BASH_ENV`` DEBUG hook) lands inside the
payload, so a caller that writes the bytes back must verify them independently (MORE). The
status rides in its own trailing segment so a failed BODY is still told apart from an empty
file. ``(None, None, reply)`` when no fenced reply came back — the command never ran as
written.
"""
sentinel = _new_sentinel(_BYTES_SENTINEL_PREFIX)
mark = f"echo {sentinel}"
rest = "".join(f"{mark}; {cmd}; " for cmd in more)
# xtrace off first: a traced ``+ echo <sentinel>`` line is an extra separator, and the
# traces of the transport commands would land inside the payload segments.
result = self._exec(f"{{ set +x; }} 2>/dev/null; {mark}; {body}; __hb=$?; {rest}{mark}; echo $__hb")
segments = _split_segments(result.stdout or "", sentinel)
if len(segments) != len(more) + 3:
return None, None, result
try:
return segments[1:-1], int(_strip_terminal_fence_leaks(segments[-1]).split()[0]), result
except (IndexError, ValueError):
return segments[1:-1], None, result
@staticmethod
def _matches_size(data: bytes, size_segment: str) -> bool:
"""Whether DATA is exactly as long as the file's own ``wc -c``. Noise inside the payload
only ever ADDS text, and any addition that still decodes adds bytes, so equal length is
the check; noise in the size segment breaks its single-integer shape instead."""
tokens = _strip_terminal_fence_leaks(size_segment).split()
return len(tokens) == 1 and tokens[0].isdigit() and int(tokens[0]) == len(data)
def _sample_file_bytes(self, path: str, length: int = 1000):
"""First ``length`` raw bytes, base64-wrapped so they survive the terminal
transport (which decodes stdout with ``errors="replace"`` and manufactures
U+FFFD for every undecodable byte, including a multibyte char cut in half
by ``head -c``). None when no clean base64 came back (no ``base64`` binary);
callers then fall back to the text heuristic.
Wrapping the sample in base64 lets the original bytes survive the transport, so binary detection can
happen at the byte layer where it is well-defined (#80308 and friends). Fenced like the
byte-exact read below: this sample is the binary-admission gate in FRONT of that read, so
backend noise decoded into it decides whether a file is editable at all.
"""
segments, read_rc, _ = self._fenced_read(
f"head -c {length} {self._escape_shell_arg(path)} 2>/dev/null | base64")
if segments is None or read_rc != 0:
return None
return self._decode_base64_sample(segments[0])
def _read_exact_bytes(self, path: str) -> "tuple[Optional[bytes], Optional[ExecuteResult]]":
"""The file's bytes exactly, for the edit paths that write back every line they did not touch.
The text transport cannot carry them: it decodes with errors="replace", so a byte UTF-8 cannot
decode comes back as U+FFFD and the edit then persists it. A native read on the local POSIX host,
else base64 over the transport; ``(None, result)`` hands back the failed shell read for the
caller's message. Only a regular file gets a native open (a FIFO would block this thread); the
rest take the shell path and its timeout, as before."""
if self._native_read_enabled():
import stat as _stat
full = path if os.path.isabs(path) else os.path.join(
getattr(self.env, "cwd", None) or self.cwd, path)
try:
# One lookup, not two: a stat-then-open pair can have the path swapped for a FIFO in
# between, and that open blocks this thread forever (no backend timeout covers it).
# O_NONBLOCK returns a descriptor for a FIFO instead of waiting, and fstat judges THAT
# descriptor, so a non-regular file is rejected rather than read.
fd = os.open(full, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
try:
if _stat.S_ISREG(os.fstat(fd).st_mode):
with open(fd, "rb", closefd=False) as fh:
return fh.read(), None
finally:
os.close(fd)
except OSError:
pass # missing/unreadable/would-block: the shell read below reports it the usual way
# Fenced like the compound read probe, and for the same reason: a backend whose merged
# stdout carries login-shell noise (a remote shell announcing TERM, a banner) would
# otherwise have it whitespace-joined onto the payload and decoded INTO the file's bytes,
# which the edit paths then write back. The file's own byte count travels beside it: output
# INSIDE the fence decodes too, so only a read that matches it is ever handed to a writer.
arg = self._escape_shell_arg(path)
segments, read_rc, result = self._fenced_read(f"base64 < {arg}", f"wc -c < {arg}")
garbled = ExecuteResult(stdout=f"{path}: the backend returned a garbled byte-exact read", exit_code=1)
if segments is None:
# No fenced reply: the command never ran as written (a wrapper ``cd`` failed, the backend
# refused it). Hand the backend's own text back so the caller reports what it said.
return None, result if result.exit_code != 0 else garbled
if read_rc is None:
return None, garbled
if read_rc == 127: # no base64 on this backend (busybox, distroless): try the hex transport
return self._read_exact_bytes_hex(path)
payload, size = segments
if read_rc != 0:
# stderr is merged into the fenced segment, so that segment holds base64's own diagnostic
# ("No such file or directory", "Permission denied"): keep it for the caller's message.
return None, self._failed_read(path, payload, read_rc)
data = self._decode_base64_sample(payload)
if data is None or not self._matches_size(data, size): # stray output: refuse, never echo it back
return None, garbled
return data, None
@staticmethod
def _failed_read(path: str, payload: str, read_rc: int) -> ExecuteResult:
"""The backend's own diagnostic for a read that ran and failed, else a bare exit status."""
return ExecuteResult(stdout=_strip_terminal_fence_leaks(payload).strip() or f"{path}: exit {read_rc}",
exit_code=read_rc)
def _read_exact_bytes_hex(self, path: str) -> "tuple[Optional[bytes], Optional[ExecuteResult]]":
"""``od`` fallback for a backend without ``base64``, fenced the same way.
``read_file_raw`` is the edit paths' source read AND, through ``_apply_add``, their
existence check, so a transport that simply is not installed must not read as "no such
file" — that clobbers the file the Add was refusing to overwrite. ``od`` is POSIX and
present in busybox; when it is missing too the caller gets a transport error, never a
not-found."""
arg = self._escape_shell_arg(path)
segments, read_rc, result = self._fenced_read(f"od -An -v -tx1 < {arg}", f"wc -c < {arg}")
unavailable = ExecuteResult(
stdout=f"{path}: this backend has neither base64 nor od, so a byte-exact read is unavailable",
exit_code=1)
if segments is None:
return None, result if result.exit_code != 0 else unavailable
if read_rc is None or read_rc == 127:
return None, unavailable
payload, size = segments
if read_rc != 0:
return None, self._failed_read(path, payload, read_rc)
try:
data = bytes.fromhex("".join(_strip_terminal_fence_leaks(payload).split()))
except ValueError:
data = None
if data is None or not self._matches_size(data, size):
return None, ExecuteResult(stdout=f"{path}: the backend returned a garbled byte-exact read",
exit_code=1)
return data, None
@staticmethod
def _decode_base64_sample(text: str) -> Optional[bytes]:
"""Decode one ``base64`` transport reply (a ``head -c N`` sample or a whole file). Whitespace-joins
the whole text first (``base64`` wraps at 76 columns), so callers hand over exactly one
segment; anything else fails validation → None."""
encoded = "".join(_strip_terminal_fence_leaks(text).split())
if not encoded:
return b""
if not re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", encoded):
return None
try:
return base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
return None
@staticmethod
def _is_likely_binary_bytes(sample: bytes) -> bool:
"""Byte-layer binary detection: text iff valid UTF-8, allowing one incomplete
multibyte sequence at the very end (artifact of the byte-boundary cut).
NUL bytes or mid-stream invalid UTF-8 stay read-only so a read→edit→write
round-trip never rewrites undecodable bytes as U+FFFD; a file that
legitimately CONTAINS U+FFFD is valid UTF-8 and reads as text.
See #80308.
"""
if not sample:
return False
if b"\x00" in sample:
return True
try:
sample.decode("utf-8")
return False
except UnicodeDecodeError as exc:
# UTF-8 sequences are at most 4 bytes: an error starting in the
# last 3 bytes with a clean prefix is a boundary cut, not binary.
if exc.start >= len(sample) - 3:
try:
sample[: exc.start].decode("utf-8")
return False
except UnicodeDecodeError:
pass
return True
def _is_likely_binary(self, path: str, content_sample: str = None) -> bool:
"""Legacy text-layer binary check: extension, else >30% non-printable chars."""
if has_binary_extension(path):
return True
if content_sample:
# Undecodable bytes arrive as U+FFFD ("printable", so the ratio misses
# them); treat as binary so a round-trip can't write back mojibake.
if "\ufffd" in content_sample[:1000]:
return True
non_printable = sum(1 for c in content_sample[:1000] if ord(c) < 32 and c not in '\n\r\t')
return non_printable / min(len(content_sample), 1000) > 0.30
return False
def _is_image(self, path: str) -> bool:
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS
def _add_line_numbers(self, content: str, start_line: int = 1) -> str:
"""Prefix each line with a compact ``<n>|`` gutter, clamping long lines. Not
fixed-width: padding cost ~16% more tokens per line for no accuracy gain in
A/B, while dropping numbers regressed line-referencing."""
from tools.tool_output_limits import get_max_line_length
max_line_length = get_max_line_length()
# A trailing newline terminates the final line — it does not start a new,
# empty one. Splitting without dropping it rendered a phantom "<N+1>|"
# gutter line on every newline-terminated file (`cat -n` semantics).
# Exactly ONE terminator is dropped, so a genuinely selected trailing
# blank line in a page keeps its own number.
if content.endswith('\n'):
content = content[:-1]
return '\n'.join(
f"{i}|{line if len(line) <= max_line_length else line[:max_line_length] + '... [truncated]'}"
for i, line in enumerate(content.split('\n'), start=start_line))
def _expand_path(self, path: str) -> str:
"""Expand ``~`` / ``~user`` via the backend's shell (its HOME, not the
host's). A host path under the configured workspace mount is rewritten
to that container path first, so a Windows drive path is readable
inside Docker. Must run BEFORE shell escaping — ~ doesn't expand in quotes."""
from tools.terminal_tool_config import translate_mounted_host_path
host_root = getattr(self.env, "host_cwd", None)
container_root = getattr(self.env, "host_cwd_mount", None) or "/workspace"
translated = translate_mounted_host_path(path, host_root or "", container_root)
if translated:
return translated
if not path or not path.startswith('~'):
return path
result = self._exec("echo $HOME")
if result.exit_code == 0 and result.stdout.strip():
home = result.stdout.strip()
if path == '~':
return home
if path.startswith('~/'):
return home + path[1:]
# ~username: validate and expand ONLY that token, so neither "~; rm -rf /"
# nor "~user/$(malicious)" reaches the shell.
rest = path[1:]
slash_idx = rest.find('/')
username = rest[:slash_idx] if slash_idx >= 0 else rest
if username and re.fullmatch(r'[a-zA-Z0-9._-]+', username):
expand_result = self._exec(f"echo ~{username}")
if expand_result.exit_code == 0 and expand_result.stdout.strip():
return expand_result.stdout.strip() + path[1 + len(username):]
return path
def _escape_shell_arg(self, arg: str, translate_path: bool = True) -> str:
"""Escape a string for safe use in shell commands.
On Windows native drive paths (``C:\\Users\\x`` / ``C:/Users/x``)
and mixed MSYS leftovers (``/c/Users\\x``) are rewritten to the
Git Bash ``/c/Users/x`` form via ``_bash_safe_path``: bash eats
backslashes and MSYS otherwise mangles drive paths into the
``Directory \\drivers\\etc does not exist`` failure class. Reuses
the env-layer translator so shell file ops and the terminal ``cd``
agree on the path form. No-op off Windows and for plain POSIX paths.
``translate_path=False`` skips that translation for non-path values
such as regex patterns. Backslash compensation applies only to the
local Windows argv transport. Serialized shell text stays literal.
"""
from tools.environments.local import _IS_WINDOWS, _bash_safe_path
if translate_path:
arg = _bash_safe_path(arg)
elif _IS_WINDOWS and getattr(self.env, "is_local", False):
arg = arg.replace("\\", "\\\\")
# Use single quotes and escape any single quotes in the string
return "'" + arg.replace("'", "'\"'\"'") + "'"
def _escape_native_tool_arg(self, arg: str) -> str:
"""Quote a path for a NATIVE Windows binary (rg, node, git ...): those don't
understand the MSYS ``/c/...`` form and Hermes disables MSYS argument
conversion, so nothing translates it back (→ ``os error 3``). ``C:/Users/x``
is accepted by every layer. Identical to ``_escape_shell_arg`` off Windows."""
from tools.environments.local import _IS_WINDOWS, _msys_to_windows_path
if _IS_WINDOWS and arg:
arg = _msys_to_windows_path(arg).replace("\\", "/")
return "'" + arg.replace("'", "'\"'\"'") + "'"
def _atomic_write(self, path: str, content: str) -> "ExecuteResult":
"""Write ``content`` atomically: stdin → temp file in the SAME directory →
``mv -f`` (same-FS rename; cross-device ``mv`` is copy+unlink, NOT atomic).
``mkdir -p`` folded in. Exit 0 = swap happened; non-zero = original intact.
Symlink targets are resolved first (replacing the link would orphan the
target) and the temp dir recomputed from the RESOLVED target. Existing
target: mode copied via ``stat`` (GNU ``-c%a`` / BSD ``-f%Lp``) + ``chmod``
(``chmod --reference`` is GNU-only). New target: ``chmod "=rw"`` AFTER cat
gives umask-default perms instead of mktemp's 0600 — not ``$(umask)``
arithmetic (zsh parses leading-zero constants as decimal), quoted so zsh
doesn't =word-expand. ``trap ... EXIT`` removes the temp on every failure.
"""
q_path = self._escape_shell_arg(path)
q_parent = self._escape_shell_arg(os.path.dirname(path) or ".")
tmpl = self._escape_shell_arg(".hermes-tmp.XXXXXX")
script = (
"set -e; "
# One shell script, fully quoted. Notes: - `mkdir -p "$d"` is folded in here so the parent
# directory is created in the same subprocess that writes the temp file — saves one entire
# subprocess spawn vs. a separate mkdir call. - `mktemp` lands the temp in the target's own dir
# (-p) so `mv` is same-FS atomic; we fall back to a PID-stamped name if the backend lacks mktemp
# (rare; busybox/macOS/Linux all ship it). - `chmod --reference` is GNU-only, so we read the
# octal mode with `stat` (GNU `-c%a` or BSD `-f%Lp`) and `chmod` it explicitly; silent
# best-effort — a perms-copy failure must not abort the write (the file then lands at mktemp's
# 0600, same as pre-fix). - brand-new targets get `chmod "=rw"` — the POSIX who-less symbolic
# form, which sets rw minus the process umask (e.g. 0644 under umask 022) instead of mktemp's
# hardcoded 0600 (#70856). Deliberately NOT shell arithmetic on `$(umask)`: zsh (reachable via
# _find_bash's $SHELL fallback) parses leading-zero constants as decimal and silently computes a
# garbage mode, while `chmod "=rw"` is spec-identical in bash/dash/ash/zsh and degrades to 0600
# (pre-fix behavior) if an exotic chmod rejects it. - `trap ... EXIT` guarantees the temp is
# removed on every error path (cat failure, mv failure, signal) but NOT after a successful mv
# (the temp no longer exists by then). - we `cat >` the temp, then `mv -f` it over the target.
f"d={q_parent}; t={q_path}; "
'if [ -L "$t" ]; then '
'rt="$(readlink -f "$t" 2>/dev/null || realpath "$t" 2>/dev/null || true)"; '
'[ -n "$rt" ] && { t="$rt"; d="$(dirname "$t")"; }; '
"fi; "
'mkdir -p "$d"; '
'tmp="$(mktemp -p "$d" ' + tmpl + ' 2>/dev/null '
'|| mktemp "$d/.hermes-tmp.$$.XXXXXX" 2>/dev/null '
'|| { tmp="$d/.hermes-tmp.$$"; : > "$tmp" && echo "$tmp"; })"; '
'[ -n "$tmp" ] || { echo "atomic write: could not create temp file" >&2; exit 1; }; '
"trap 'rm -f \\\"$tmp\\\"' EXIT; "
'if [ -e "$t" ]; then '
'm="$(stat -c%a "$t" 2>/dev/null || stat -f%Lp "$t" 2>/dev/null || true)"; '
'[ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true; '
"fi; "
'cat > "$tmp"; '
# new file: umask-default perms instead of mktemp's 0600 (#70856). Runs AFTER cat so a
# write-masking umask can't EACCES the stream; quoted "=rw" so zsh doesn't =word-expand it.
'if [ ! -e "$t" ]; then chmod "=rw" "$tmp" 2>/dev/null || true; fi; '
'mv -f "$tmp" "$t"; '
"trap - EXIT")
return self._exec(script, stdin_data=content)
def _file_has_bom(self, path: str, pre_content: Optional[str] = None) -> bool:
"""Whether the on-disk file starts with a UTF-8 BOM. ALWAYS probes disk:
``pre_content`` usually comes from ``read_file_raw``, which strips BOMs, so
trusting it would silently drop the marker on rewrite. Missing → False."""
head_result = self._head(path, 3)
return head_result.exit_code == 0 and _has_bom(head_result.stdout)
def _unified_diff(self, old_content: str, new_content: str, filename: str) -> str:
return ''.join(difflib.unified_diff(
old_content.splitlines(keepends=True), new_content.splitlines(keepends=True),
fromfile=f"a/{filename}", tofile=f"b/{filename}"))
# --- READ ---------------------------------------------------------------
@staticmethod
def _not_regular_error(path: str) -> ReadResult:
"""Error for a path that exists but would block if read."""
return ReadResult(error=(
f"Cannot read '{path}': not a regular file (directory, dangling symlink, "
"FIFO, socket, or device). Reading it could block indefinitely."))
def _probe_regular_file(self, path: str) -> tuple[int, str]:
"""Byte size of a REGULAR file: ``(file_size, status)`` with status ``"ok"``,
``"missing"``, ``"not_regular"``, ``"bad_size"`` (unparseable ``wc``),
``"env_unavailable"``, or the named working-directory error when the exec
wrapper itself failed (``_env_unavailable_error`` surfaces it verbatim).
``wc -c <`` on a writer-less FIFO/socket//dev/zero blocks forever and a
name-based blocklist can't cover a FIFO (a file TYPE at any path); ``[ -f ]``
is a stat (symlinks followed) so it answers without touching content. A dangling
symlink is ``not_regular``, never ``missing``: the entry exists, and a writer
told the path is free would follow the link and create its target."""
arg = self._escape_shell_arg(path)
# A missing path ECHOES its sentinel: a non-zero exit with no sentinel means the shell itself did
# not run (container still starting, removed out-of-band, transport down) — not a missing file.
# Reporting that as "File not found" made the model trust a false negative for the whole session.
stat_result = self._exec(
f"if [ -f {arg} ]; then wc -c < {arg} 2>/dev/null; "
f"elif [ -e {arg} ] || [ -L {arg} ]; then echo {NOT_REGULAR_SENTINEL}; "
f"else echo {MISSING_SENTINEL}; fi")
stat_output = _strip_terminal_fence_leaks(stat_result.stdout).strip()
if stat_output == MISSING_SENTINEL:
return 0, "missing"
if stat_output == NOT_REGULAR_SENTINEL:
return 0, "not_regular"
if stat_result.exit_code != 0:
return 0, stat_result.cwd_error or "env_unavailable"
try:
return int(stat_output), "ok"
except ValueError:
return 0, "bad_size"
def _env_unavailable_error(self, path: str, status: str = "env_unavailable") -> ReadResult:
if status != "env_unavailable":
return ReadResult(error=status)
return ReadResult(error=(f"Terminal environment unavailable: could not stat {path} "
"(the sandbox may still be starting or was removed). Retry shortly."))
def _detect_binary(self, path: str) -> tuple[bool, Optional[bytes]]:
"""``(is_binary, sample_bytes)`` — byte-layer detection when the transport
allows (base64 sample), else the legacy text heuristic (sample is None)."""
sample_bytes = self._sample_file_bytes(path)
if sample_bytes is not None:
ext_binary = has_binary_extension(path)
return ext_binary or self._is_likely_binary_bytes(sample_bytes), sample_bytes
sample_output = _strip_terminal_fence_leaks(self._head(path, 1000).stdout)
return self._is_likely_binary(path, sample_output), None
# UTF-16 rescue: trust a BOM first, then zero-byte PARITY (not density, so
# mixed Latin/CJK still detects): zeros at odd indices → UTF-16 LE, at even
# → BE; both parities or a single zero → real binary. Legacy 8-bit
# encodings (GBK, Big5) are never guessed — a wrong silent guess is worse
# than a clear refusal.
# UTF-16 rescue constants (ported from MoonshotAI/kimi-code#2647, detection derived from VS Code's
# encoding sniffer): sample the leading bytes; trust a BOM first, then a zero-byte parity heuristic —
# zeros clustering at odd indices mean UTF-16 LE (`0xAA 0x00`), at even indices UTF-16 BE (`0x00 0xAA`).
_UTF16_MAX_BYTES = 10 * 1024 * 1024
_UTF16_SAMPLE_BYTES = 512
def _python_interpreter_cmd(self) -> str:
"""Return a shell-safe Python interpreter for the terminal backend.
On the local backend ``sys.executable`` is always a working
interpreter — and on Windows it dodges the Microsoft Store
``python``/``python3`` alias stub (exit 49, "Python was not
found") that otherwise breaks inline ``-c`` snippets. Remote
backends (docker/ssh/...) don't have the agent's interpreter, so
they fall back to ``python3`` on their own PATH (the ``python``
fallback is handled at the call site).
"""
if self._lsp_local_only():
return self._escape_shell_arg(sys.executable)
return "python3"
def _exec_python_snippet(self, snippet: str, py: str = None) -> ExecuteResult:
"""Run a Python ``snippet`` in the terminal backend's interpreter.
Base64-encodes the snippet so it survives every shell/quoting layer
as pure ASCII: Windows ``subprocess`` list-arg quoting and ``bash``
double-quote processing both eat backslashes, which otherwise
corrupts Windows paths (``C:\\Users\\x``) and byte literals
(``b'\\xfe\\xff'``) embedded in a ``-c`` program. ``exec`` decodes
and runs it unchanged.
"""
encoded = base64.b64encode(snippet.encode("utf-8")).decode("ascii")
if py is None:
py = self._python_interpreter_cmd()
return self._exec(
f"{py} -c \"import base64; exec(base64.b64decode('{encoded}').decode())\""
)
def _try_read_utf16(self, path: str, offset: int, limit: int,
file_size: int) -> "Optional[ReadResult]":
"""Read ``path`` as UTF-16 transcoded to UTF-8, or None (caller falls back
to the binary-file error). Skips known-binary extensions and files over
10 MiB. ``path`` must already be expanded."""
if has_binary_extension(path) or file_size > self._UTF16_MAX_BYTES:
return None
snippet = (
"import sys, json, os\n"
f"p = {json.dumps(path)}\n"
f"offset = {int(offset)}\n"
f"limit = {int(limit)}\n"
f"MAX = {self._UTF16_MAX_BYTES}\n"
f"SAMPLE = {self._UTF16_SAMPLE_BYTES}\n"
"try:\n"
" size = os.path.getsize(p)\n"
" if size > MAX:\n"
" print('HERMES_UTF16:NO'); sys.exit(0)\n"
" with open(p, 'rb') as f:\n"
" data = f.read()\n"
" sample = data[:SAMPLE]\n"
" enc = None\n"
" if sample[:2] == b'\\xfe\\xff':\n"
" enc = 'utf-16-be'\n"
" elif sample[:2] == b'\\xff\\xfe':\n"
" enc = 'utf-16-le'\n"
" else:\n"
" odd = sum(1 for i in range(1, len(sample), 2) if sample[i] == 0)\n"
" even = sum(1 for i in range(0, len(sample), 2) if sample[i] == 0)\n"
" if even == 0 and odd >= 2:\n"
" enc = 'utf-16-le'\n"
" elif odd == 0 and even >= 2:\n"
" enc = 'utf-16-be'\n"
" if enc is None:\n"
" print('HERMES_UTF16:NO'); sys.exit(0)\n"
" text = data.decode(enc, 'replace')\n"
" if text[:1] == '\\ufeff':\n"
" text = text[1:]\n"
" text = text.replace('\\r\\n', '\\n')\n"
" lines = text.split('\\n')\n"
" total = len(lines)\n"
" sel = lines[offset - 1: offset - 1 + limit]\n"
" out = {'total_lines': total, 'encoding': enc,\n"
" 'content': '\\n'.join(sel)}\n"
" print('HERMES_UTF16:OK')\n"
" print(json.dumps(out, ensure_ascii=True))\n"
"except Exception:\n"
" print('HERMES_UTF16:NO'); sys.exit(0)\n"
)
result = self._exec_python_snippet(snippet)
if result.exit_code != 0 and "python3" in (result.stdout or ""):
result = self._exec_python_snippet(snippet, py="python")
stdout = _strip_terminal_fence_leaks(result.stdout or "")
marker = stdout.find("HERMES_UTF16:OK")
if result.exit_code != 0 or marker < 0:
return None
payload = stdout[marker + len("HERMES_UTF16:OK"):].strip()
try:
data = json.loads(payload.split("\n", 1)[0] if "\n" in payload else payload)
content = data["content"]
total_lines = int(data["total_lines"])
encoding = str(data.get("encoding", "utf-16"))
except (ValueError, KeyError, TypeError):
return None
end_line = offset + limit - 1
truncated = total_lines > end_line
hint_parts = [f"Transcoded from {encoding.upper()} to UTF-8 for display. "
"Text edits via patch/write_file would re-encode as UTF-8."]
if truncated:
hint_parts.append(
f"Use offset={end_line + 1} to continue reading "
f"(showing {offset}-{end_line} of {total_lines} lines)")
from tools.tool_output_limits import get_max_line_length
max_line_length = get_max_line_length()
truncated_lines = any(len(line) > max_line_length for line in content.split('\n'))
return ReadResult(
content=self._add_line_numbers(content, offset), total_lines=total_lines,
file_size=file_size, truncated=truncated, hint=" ".join(hint_parts),
truncated_lines=True if truncated_lines else None)
def read_file(self, path: str, offset: int = 1, limit: int = 2000) -> ReadResult:
"""Read a file with pagination, binary detection, and line numbers.
``offset`` is 1-indexed; ``limit`` is clamped by ``normalize_read_pagination``.
One shell round-trip answers every question the read needs (existence, size,
binary sample, page, line count, trailing newline; see ``_read_probe_cmd``).
An unparseable reply falls back to ``_read_file_sequential`` (one probe per
question), so an exotic shell can never do worse than before. On a local
POSIX environment the read never touches the shell (``_read_file_native``).
"""
path = self._expand_path(path) # before shell escaping: ~ doesn't expand in quotes
offset, limit = normalize_read_pagination(offset, limit)
if self._native_read_enabled():
return self._read_file_native(path, offset, limit)
# Images / known-binary extensions never inline content; the sequential
# path stops at the probes for them, so don't stream their bytes.
if self._is_image(path) or has_binary_extension(path):
return self._read_file_sequential(path, offset, limit)
from tools.tool_output_limits import get_max_line_length
line_clamp_bytes = 4 * get_max_line_length() + 1
end_line = offset + limit - 1
sentinel = _new_sentinel(_READ_SENTINEL_PREFIX)
probe = self._exec(self._read_probe_cmd(path, offset, end_line, line_clamp_bytes, sentinel))
output = probe.stdout or ""
if sentinel not in output:
# Single-line replies: the path is missing or not a regular file.
marker = _strip_terminal_fence_leaks(output).strip()
if marker == MISSING_SENTINEL:
return self._read_file_missing(path, offset, limit)
if marker == NOT_REGULAR_SENTINEL:
return self._not_regular_error(path)
logger.debug(
"read_file: compound probe reply for %s has no sentinel "
"(exit %s, %d chars); falling back to sequential probes",
path, probe.exit_code, len(output))
return self._read_file_sequential(path, offset, limit)
segments = _split_segments(output, sentinel)
if probe.exit_code != 0 or len(segments) != 6:
logger.debug(
"read_file: compound probe for %s returned exit %s with %d "
"segments (want 6); falling back to sequential probes",
path, probe.exit_code, len(segments))
return self._read_file_sequential(path, offset, limit)
size_seg, sample_seg, page_seg, wc_seg, tail_seg, status_seg = segments
status = _strip_terminal_fence_leaks(status_seg).split()
try:
sample_rc, read_rc = int(status[0]), int(status[1])
except (IndexError, ValueError):
logger.debug(
"read_file: compound probe for %s has unparseable status %r; "
"falling back to sequential probes", path, status_seg[-40:])
return self._read_file_sequential(path, offset, limit)
try:
file_size = int(_strip_terminal_fence_leaks(size_seg).strip())
except ValueError:
file_size = 0
# Byte-layer binary detection when base64 was available, else the legacy
# text heuristic over a plain sample (one extra round-trip, shells without base64).
sample_bytes = self._decode_base64_sample(sample_seg) if sample_rc == 0 else None
if sample_bytes is not None:
is_binary = self._is_likely_binary_bytes(sample_bytes)
else:
logger.debug(
"read_file: no usable base64 sample for %s (base64 exit %s); "
"paying one extra round-trip for the text heuristic", path, sample_rc)
sample_output = _strip_terminal_fence_leaks(self._head(path, 1000).stdout)
is_binary = self._is_likely_binary(path, sample_output)
if is_binary:
return self._read_binary_file(path, offset, limit, file_size, sample_bytes)
if read_rc != 0:
return ReadResult(error=f"Failed to read file: {_strip_terminal_fence_leaks(page_seg)}")
read_output = _strip_terminal_fence_leaks(page_seg)
try:
total_lines = int(_strip_terminal_fence_leaks(wc_seg).strip())
except ValueError:
total_lines = 0
tail_flag = _strip_terminal_fence_leaks(tail_seg).strip()
file_ends_with_newline = tail_flag == "1" if tail_flag in ("0", "1") else None
return self._assemble_read_result(
read_output, offset=offset, end_line=end_line, total_lines=total_lines,
file_size=file_size, file_ends_with_newline=file_ends_with_newline)
def _native_read_enabled(self) -> bool:
"""Whether ``read_file`` and ``search_files`` may bypass the shell: only POSIX + ``LocalEnvironment``
(file is on this host, path already native; Windows keeps the shell path since
file_operations holds Git-Bash-style paths there). ``HERMES_NATIVE_FILE_READ=0``
turns the fast path off."""
flag = os.environ.get("HERMES_NATIVE_FILE_READ", "1").strip().lower()
if flag in ("0", "false", "no", "off"):
return False
# Same "is this env the local host" test the LSP path uses; isinstance is
# microseconds and self.env is never rebound, so nothing to memoize.
return sys.platform != "win32" and self._lsp_local_only()
def _read_file_native(self, path: str, offset: int, limit: int) -> ReadResult:
"""``read_file`` without a shell — same contract as the shell path, byte for
byte. ``os.stat`` is the ``[ -f ]`` guard (a stat, never an open, so FIFOs and
devices are refused before anything touches them); the first 1000 bytes drive
the byte-layer binary check; the page is produced exactly as
``sed -n 'a,bp' | cut -b1-N`` prints it (each line clamped to N bytes and
newline-terminated) then decoded with errors="replace" like the transport.
One chunked pass counts lines and collects the page, so neither the file nor
a pathological line is ever held whole. ``path`` is already expanded; any
unexpected OSError hands over to the shell path."""
import stat as _stat
full = path if os.path.isabs(path) else os.path.join(
getattr(self.env, "cwd", None) or self.cwd, path)
try:
st = os.stat(full)
except (FileNotFoundError, NotADirectoryError):
if os.path.islink(full): # dangling: an entry, not an absent path (``_probe_regular_file``)
return self._not_regular_error(path)
return self._read_file_missing(path, offset, limit)
except OSError:
return self._read_file_sequential(path, offset, limit)
if not _stat.S_ISREG(st.st_mode):
return self._not_regular_error(path)
file_size = st.st_size
if self._is_image(path):
return self._image_redirect_result(file_size)
from tools.tool_output_limits import get_max_line_length
clamp = 4 * get_max_line_length() + 1
end_line = offset + limit - 1
page: list[bytes] = []
total_lines = 0
lineno = 1 # the line currently being scanned
kept = bytearray() # first ``clamp`` bytes of that line
have_partial = False # that line has bytes but no newline yet
last_byte = b""
digest = hashlib.sha256()
try:
with open(full, "rb") as fh:
sample = fh.read(1000)
ext_binary = has_binary_extension(path)
if ext_binary or self._is_likely_binary_bytes(sample):
return self._read_binary_file(path, offset, limit, file_size, sample)
fh.seek(0)
while True:
chunk = fh.read(1 << 20)
if not chunk:
break
digest.update(chunk)
last_byte = chunk[-1:]
if lineno > end_line:
# Past the window: only the line count and trailing byte
# are needed, so let memchr do it instead of per-line work.
total_lines += chunk.count(b"\n")
have_partial = chunk[-1:] != b"\n"
continue
pos, n = 0, len(chunk)
while pos < n:
nl = chunk.find(b"\n", pos)
in_page = offset <= lineno <= end_line
if nl < 0:
if in_page and len(kept) < clamp:
kept += chunk[pos:pos + (clamp - len(kept))]
have_partial = True
break
if in_page:
if len(kept) < clamp:
kept += chunk[pos:min(nl, pos + (clamp - len(kept)))]
page.append(bytes(kept) + b"\n")
kept = bytearray()
have_partial = False
total_lines += 1
lineno += 1
pos = nl + 1
except OSError:
return self._read_file_sequential(path, offset, limit)
if have_partial and offset <= lineno <= end_line:
# ``sed`` prints a final line that lacks a newline; ``cut`` adds one.
page.append(bytes(kept) + b"\n")
read_output = _strip_terminal_fence_leaks(b"".join(page).decode("utf-8", errors="replace"))
result = self._assemble_read_result(
read_output, offset=offset, end_line=end_line, total_lines=total_lines,
file_size=file_size,
file_ends_with_newline=(last_byte == b"\n") if file_size else None)
result._snapshot = (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns, digest.digest())
return result
@staticmethod
def _image_redirect_result(file_size: int) -> ReadResult:
return ReadResult(
is_image=True, is_binary=True, file_size=file_size,
hint=(
"Image file detected. Automatically redirected to vision_analyze tool. "
"Use vision_analyze with this file path to inspect the image contents."))
def _read_probe_cmd(self, path: str, offset: int, end_line: int,
line_clamp_bytes: int, sentinel: str) -> str:
"""One shell command answering every question ``read_file`` asks: six
segments each closed by a ``sentinel`` line — byte size, base64 of the first
1000 bytes, the ``sed | cut`` page, ``wc -l``, whether the last byte is a
newline, then the base64 and page pipeline statuses. Probes run only inside
``[ -f ]`` (stat-not-open, like ``_probe_regular_file``) so a FIFO/device never
reaches ``head``/``sed``. A missing path echoes ``MISSING_SENTINEL`` (a compound
command only reports its last status). Every stage silences stderr: the local
backend merges stderr into stdout and a stray diagnostic would land inside a
segment. The byte clamp is ``4 * max_line_length + 1``; see ``_read_file_sequential``."""
arg = self._escape_shell_arg(path)
mark = f"echo {sentinel}"
return (
f"if [ -f {arg} ]; then "
f"wc -c < {arg} 2>/dev/null; {mark}; "
f"head -c 1000 {arg} 2>/dev/null | base64 2>/dev/null; __hs=$?; {mark}; "
f"sed -n '{offset},{end_line}p' {arg} 2>/dev/null"
f" | cut -b1-{line_clamp_bytes} 2>/dev/null; __hr=$?; {mark}; "
f"wc -l < {arg} 2>/dev/null; {mark}; "
f"tail -c 1 {arg} 2>/dev/null | wc -l; {mark}; "
f'echo "$__hs $__hr"; '
f"elif [ -e {arg} ] || [ -L {arg} ]; then echo {NOT_REGULAR_SENTINEL}; "
f"else echo {MISSING_SENTINEL}; fi")
def _read_file_missing(self, path: str, offset: int, limit: int) -> ReadResult:
"""Not-found recovery shared by every read path. Unicode-equivalent spellings
(NFC/NFD, confusable spaces/quotes) render identically, so the model can never
discover the byte mismatch by retyping — retrying is the tool's job. No
equivalent spelling → suggest similar files."""
variant = self._unicode_variant_match(path)
if variant is not None:
result = self.read_file(variant, offset=offset, limit=limit)
note = (
f"Note: '{path}' not found byte-for-byte; resolved to "
f"the unicode-equivalent file '{variant}' (invisible "
"encoding difference: NFC/NFD or special space/quote "
"characters).")
result.hint = f"{note} {result.hint}" if result.hint else note
return result
return self._suggest_similar_files(path)
def _read_binary_file(self, path: str, offset: int, limit: int,
file_size: int, sample_bytes: Optional[bytes]) -> ReadResult:
"""Binary branch shared by every read path: UTF-16 text (Notepad, PowerShell
``>``) trips the binary guard; transcode it, else refuse with the type name.
UTF-16 rescue (ported from MoonshotAI/kimi-code#2647): the terminal env decodes stdout as UTF-8 with
errors="replace", so a UTF-16 text file (Windows Notepad .txt, PowerShell `>` redirects) arrives
mangled with U+FFFD and trips the binary guard. Probe the raw bytes via the backend's Python and
transcode to UTF-8 when a BOM or the zero-byte parity heuristic identifies UTF-16.
"""
utf16_result = self._try_read_utf16(path, offset, limit, file_size)
if utf16_result is not None:
return utf16_result
return ReadResult(
is_binary=True, file_size=file_size,
error=describe_binary_file(sample_bytes, file_size))
def _read_file_sequential(self, path: str, offset: int, limit: int) -> ReadResult:
"""One-probe-per-call read: the pre-compound form, kept as fallback for
image / known-binary extensions and unparseable compound replies. ``path`` is
already expanded and ``offset``/``limit`` normalized."""
file_size, status = self._probe_regular_file(path)
if status == "missing":
return self._read_file_missing(path, offset, limit)
if status == "not_regular":
return self._not_regular_error(path)
if status not in ("ok", "bad_size"):
return self._env_unavailable_error(path, status)
if self._is_image(path): # never inlined — redirect to the vision tool
return self._image_redirect_result(file_size)
is_binary, sample_bytes = self._detect_binary(path)
if is_binary:
return self._read_binary_file(path, offset, limit, file_size, sample_bytes)
# Clamp each line to a byte budget IN THE SHELL so a 400MB single-line file
# never crosses the exec transport. 4*max+1 BYTES (not max+1): ``cut -b`` can
# split a multibyte codepoint, and a tighter byte clamp would yield fewer
# CHARS than max so the Python clamp in _add_line_numbers would never fire
# (silent truncation). UTF-8 codepoints are ≤4 bytes, so every over-long
# line still trips the char clamp, which also drops a boundary-split U+FFFD.
from tools.tool_output_limits import get_max_line_length
line_clamp_bytes = 4 * get_max_line_length() + 1
end_line = offset + limit - 1
read_result = self._exec(
f"sed -n '{offset},{end_line}p' {self._escape_shell_arg(path)}"
f" | cut -b1-{line_clamp_bytes}")
if read_result.exit_code != 0:
return ReadResult(error=f"Failed to read file: {read_result.stdout}")
read_output = _strip_terminal_fence_leaks(read_result.stdout)
wc_result = self._exec(f"wc -l < {self._escape_shell_arg(path)}")
try:
total_lines = int(_strip_terminal_fence_leaks(wc_result.stdout).strip())
except ValueError:
total_lines = 0
# Only the page reaching the file's final line can carry the ``cut`` newline
# artifact (see _assemble_read_result); probe the last byte just for that case.
file_ends_with_newline: Optional[bool] = None
if not total_lines > end_line and read_output.endswith('\n'):
tail_result = self._exec(f"tail -c 1 {self._escape_shell_arg(path)} | wc -l")
if tail_result.exit_code == 0:
file_ends_with_newline = _strip_terminal_fence_leaks(tail_result.stdout).strip() != "0"
return self._assemble_read_result(
read_output, offset=offset, end_line=end_line, total_lines=total_lines,
file_size=file_size, file_ends_with_newline=file_ends_with_newline)
def _assemble_read_result(self, read_output: str, *, offset: int, end_line: int,
total_lines: int, file_size: int,
file_ends_with_newline: Optional[bool]) -> ReadResult:
"""Turn a raw ``sed | cut`` page into the final ``ReadResult``. Shared by every
read path so the BOM strip, pagination hint, ``cut`` newline-artifact fix and
the ambiguous-silence guards never drift apart. ``file_ends_with_newline`` is
None when the caller could not tell (artifact left alone, as before)."""
# ``wc -l`` counts newlines, not lines: a nonempty file whose last byte
# is not a newline holds one more line than the count (#3907). Adjust
# here — the single choke point — so total_lines, truncation, and the
# past-EOF guard agree on every read path (compound, sequential, native).
if file_size > 0 and file_ends_with_newline is False:
total_lines += 1
if offset == 1: # only the first chunk can carry a BOM (byte 0)
read_output, _ = _strip_bom(read_output)
truncated = total_lines > end_line
hint = None
if truncated:
hint = f"Use offset={end_line + 1} to continue reading (showing {offset}-{end_line} of {total_lines} lines)"
# ``cut`` always newline-terminates, so a file without a trailing newline
# would grow a phantom empty last line; strip it when the last byte says so.
if not truncated and read_output.endswith('\n') and file_ends_with_newline is False:
read_output = read_output[:-1]
# Empty content is indistinguishable from a broken tool: name the dead end.
if file_size == 0:
return ReadResult(content="", total_lines=0, file_size=0, hint="File is empty (0 bytes).")
if offset > total_lines > 0:
return ReadResult(
content="", total_lines=total_lines, file_size=file_size,
hint=(
f"Note: offset {offset} is beyond the end of the file "
f"({total_lines} lines total). Retry with offset <= "
f"{total_lines}."))
from tools.tool_output_limits import get_max_line_length
max_line_length = get_max_line_length()
truncated_lines = any(len(line) > max_line_length for line in read_output.split('\n'))
return ReadResult(
content=self._add_line_numbers(read_output, offset), total_lines=total_lines,
file_size=file_size, truncated=truncated, hint=hint,
truncated_lines=True if truncated_lines else None)
# Confusable characters seen in real filenames, collapsed after NFC.
_CONFUSABLES = (
("\u202f", " "), # narrow no-break space (macOS screenshots)
("\u00a0", " "), # no-break space
("\u2019", "'"), # right single quotation mark (Finder)
("\u2018", "'"), # left single quotation mark
)
def _unicode_variant_match(self, path: str) -> Optional[str]:
"""On-disk spelling of a file whose name is unicode-equivalent to ``path``
(NFC/NFD, confusable spaces/quotes). Returns the entry only when EXACTLY one
matches — several candidates = homoglyph collision, guessing would read the
wrong file."""
dir_path = os.path.dirname(path) or "."
filename = os.path.basename(path)
if not filename:
return None
def _canon(name: str) -> str:
out = unicodedata.normalize("NFC", name)
for src, dst in self._CONFUSABLES:
out = out.replace(src, dst)
return out
target = _canon(filename)
ls_result = self._exec(f"ls -1 {self._escape_shell_arg(dir_path)} 2>/dev/null")
if ls_result.exit_code != 0 or not ls_result.stdout.strip():
return None
candidates = [
entry for entry in _strip_terminal_fence_leaks(ls_result.stdout).splitlines()
if entry and entry != filename and _canon(entry) == target]
if len(candidates) == 1:
return os.path.join(dir_path, candidates[0]) if dir_path != "." or "/" in path else candidates[0]
return None
def _suggest_similar_files(self, path: str) -> ReadResult:
""""File not found" result listing up to 5 similar names from the same directory."""
dir_path = os.path.dirname(path) or "."
filename = os.path.basename(path)
basename_no_ext = os.path.splitext(filename)[0].lower()
ext = os.path.splitext(filename)[1].lower()
lower_name = filename.lower()
ls_result = self._exec(f"ls -1 {self._escape_shell_arg(dir_path)} 2>/dev/null | head -50")
scored: list = [] # (score, filepath) — higher is better
if ls_result.exit_code == 0 and ls_result.stdout.strip():
for f in ls_result.stdout.strip().split('\n'):
if not f:
continue
lf = f.lower()
score = 0
if lf == lower_name:
score = 100
elif os.path.splitext(f)[0].lower() == basename_no_ext: # config.yml vs config.yaml
score = 90
elif lf.startswith(lower_name) or lower_name.startswith(lf):
score = 70
elif lower_name in lf:
score = 60
elif lf in lower_name and len(lf) > 2:
score = 40
elif ext and os.path.splitext(f)[1].lower() == ext:
common = set(lower_name) & set(lf)
if len(common) >= max(len(lower_name), len(lf)) * 0.4:
score = 30
# Near-miss spelling (AGENT.md -> AGENTS.md) the substring checks miss.
if score == 0 and difflib.SequenceMatcher(None, lower_name, lf).ratio() >= 0.8:
score = 50
if score > 0:
scored.append((score, os.path.join(dir_path, f)))
scored.sort(key=lambda x: -x[0])
return ReadResult(error=f"File not found: {path}", not_found=True,
similar_files=[fp for _, fp in scored[:5]])
def read_file_raw(self, path: str) -> ReadResult:
"""Whole file as a plain string (no pagination/line numbers/clamping)."""
path = self._expand_path(path)
file_size, status = self._probe_regular_file(path)
if status == "missing":
return self._suggest_similar_files(path)
if status == "not_regular":
return self._not_regular_error(path)
if status not in ("ok", "bad_size"):
return self._env_unavailable_error(path, status)
if self._is_image(path):
return ReadResult(is_image=True, is_binary=True, file_size=file_size)
is_binary, sample_bytes = self._detect_binary(path)
if is_binary:
return ReadResult(is_binary=True, file_size=file_size, error=describe_binary_file(sample_bytes, file_size))
data, failed = self._read_exact_bytes(path)
if data is None:
return ReadResult(error=f"Failed to read file: {failed.stdout}")
# V4A writes this back, so no display cleanup (nothing has emitted the __HERMES_FENCE_ wrapper it
# targets since d684d7ee7e; it can only eat the file's own escape bytes), and surrogateescape
# so write_file's encode restores any byte past the sample that UTF-8 cannot decode (#79178).
# Strip a leading BOM (a phantom U+FEFF defeats an exact first-line match);
# write_file re-probes disk and restores it.
raw_content, _ = _strip_bom(data.decode("utf-8", "surrogateescape"))
return ReadResult(content=raw_content, file_size=file_size)
def read_file_bytes(self, path: str, max_bytes: Optional[int] = None) -> ReadResult:
"""Read binary-safe bytes (as base64) from any shell-backed environment."""
path = self._expand_path(path)
file_size, status = self._probe_regular_file(path)
if status == "missing":
return ReadResult(error=f"File not found: {path}", not_found=True)
if status == "not_regular":
return self._not_regular_error(path)
if status not in ("ok", "bad_size"):
return self._env_unavailable_error(path, status)
if status == "bad_size":
return ReadResult(error=f"Could not determine file size: {path}")
if max_bytes is not None and file_size > max_bytes:
return ReadResult(
file_size=file_size,
error=f"File is too large ({file_size:,} bytes, limit is {max_bytes:,})")
encoded = self._exec(f"base64 < {self._escape_shell_arg(path)}")
if encoded.exit_code != 0:
return ReadResult(error=f"Failed to read binary file: {encoded.stdout}")
compact = "".join(_strip_terminal_fence_leaks(encoded.stdout).split())
try:
base64.b64decode(compact, validate=True)
except (ValueError, base64.binascii.Error):
return ReadResult(error=f"Backend returned invalid binary data for: {path}")
return ReadResult(base64_content=compact, file_size=file_size, is_binary=True)
def delete_file(self, path: str) -> WriteResult:
"""Delete a single file (directories rejected) via the backend's ``python -c``
so one code path works on local/docker/ssh AND Windows shells (no ``rm``)."""
path = self._expand_path(path)
# Delete removes the directory entry (a symlink itself, not its target), so
# the guards vet the entry as well as the target it resolves to.
denied = get_write_denied_error(path, verb="Delete", entry=True)
if denied:
return WriteResult(error=denied)
# Path baked in via repr() for shell-independent quoting; no
# ``unlink(missing_ok=True)`` (a 3.7 remote interpreter lacks it).
snippet = (
"import shutil, pathlib, sys\n"
f"p = pathlib.Path({json.dumps(path)})\n"
"recursive = False\n"
"try:\n"
" if p.is_dir() and not p.is_symlink():\n"
" if recursive:\n"
" shutil.rmtree(p)\n"
" else:\n"
" print('is a directory: ' + str(p), file=sys.stderr); sys.exit(2)\n"
" else:\n"
" p.unlink()\n"
"except FileNotFoundError:\n"
" pass\n"
"except Exception as exc:\n"
" print(str(exc), file=sys.stderr); sys.exit(1)\n"
)
result = self._exec_python_snippet(snippet)
# Fall back to ``python`` (remote backends / older systems where there's no
# ``python3`` symlink but a ``python`` binary is on PATH).
if result.exit_code != 0 and "python3" in (result.stdout or ""):
result = self._exec_python_snippet(snippet, py="python")
if result.exit_code != 0:
return WriteResult(error=f"Failed to delete {path}: {(result.stdout or '').strip() or 'unknown error'}")
return WriteResult()
def move_file(self, src: str, dst: str) -> WriteResult:
src = self._expand_path(src)
dst = self._expand_path(dst)
# Entry-level op like delete_file: vet both entries, not just their targets.
for p in (src, dst):
denied = get_write_denied_error(p, verb="Move", entry=True)
if denied:
return WriteResult(error=denied)
result = self._exec(f"mv {self._escape_shell_arg(src)} {self._escape_shell_arg(dst)}")
if result.exit_code != 0:
return WriteResult(error=f"Failed to move {src} -> {dst}: {result.stdout}")
return WriteResult()
# --- WRITE --------------------------------------------------------------
# Lone surrogates OUTSIDE the surrogateescape range (U+DC80-U+DCFF round-trips
# through the pipe; anything else can't be encoded at all).
_LONE_SURROGATE_RE = re.compile(r"[\ud800-\udc7f\udd00-\udfff]")
def _reject_unencodable(self, path: str, content: str) -> Optional[WriteResult]:
"""Refuse content with a lone surrogate BEFORE any subprocess: letting it
reach the pipe spawns a child that hangs or truncates the target via
empty-stdin ``cat``. A regex scan needs no encode."""
m = self._LONE_SURROGATE_RE.search(content)
if m:
return WriteResult(error=(
f"Refusing to write '{path}': content contains a lone "
f"surrogate character ({m.group(0)!r}) that cannot be "
"encoded as UTF-8. The file was NOT created or modified."))
return None
@staticmethod
def _fail_closed_syntax_error(path: str, ext: str, content: str) -> Optional[WriteResult]:
"""Fail-closed pre-write gate for ``_FAIL_CLOSED_INPROC_EXTS`` (JSON/YAML/TOML):
a structured-format write that doesn't parse is a corrupt write, so refuse
before any bytes touch disk. Checked against the RAW content, before the
BOM/CRLF shims (post-shim linting would false-positive on a BOM-marked file)."""
linter = LINTERS_INPROC.get(ext) if ext in _FAIL_CLOSED_INPROC_EXTS else None
if linter is None:
return None
ok, err = linter(content)
if ok or err == "__SKIP__":
return None
return WriteResult(error=(
f"Refusing to write '{path}': candidate content fails "
f"{ext} syntax validation ({err}). The file was "
"NOT created or modified. Fix the content and retry."))
def _write_probe_cmd(self, path: str, sentinel: str, body: Optional[str]) -> str:
"""One shell command for the on-disk questions ``write_file`` asks. Two
segments closed by a ``sentinel`` line: base64 of the first three bytes (BOM
detection at the byte layer, same on-disk truth as ``_file_has_bom``), then
``body``: ``"cat"`` for the full text when pre-content is wanted, ``"sample"``
for the 4 KB line-ending sample, or None for nothing. Gated on ``[ -f ]`` so a
FIFO/device never reaches ``head``/``cat``; a missing path echoes ``MISSING_SENTINEL``."""
arg = self._escape_shell_arg(path)
if body == "cat":
body_cmd = f"cat {arg} 2>/dev/null"
elif body == "sample":
body_cmd = f"head -c 4096 {arg} 2>/dev/null"
else:
body_cmd = ":"
return (
f"if [ -f {arg} ]; then "
f"head -c 3 {arg} 2>/dev/null | base64 2>/dev/null; echo {sentinel}; "
f"{body_cmd}; "
f"else echo {MISSING_SENTINEL}; fi")
def _probe_write_target(self, path: str, pre_content: Optional[str], want_pre: bool,
) -> tuple[bool, Optional[str], Optional[str]]:
"""``(has_bom, pre_content, original_line_ending)`` for ``path`` in ONE
round-trip (replaces ``cat`` when pre-content is wanted, a ``head -c 4096``
line-ending sample and a ``head -c 3`` BOM check). Semantics unchanged:
pre-content is read only when wanted and not supplied; the line ending comes
from pre-content when there is any, else from the sample; the BOM always comes
from disk. An unparseable reply falls back to the separate probes."""
if want_pre and pre_content is None:
body_mode: Optional[str] = "cat"
elif not pre_content:
body_mode = "sample"
else:
body_mode = None
sentinel = _new_sentinel(_WRITE_SENTINEL_PREFIX)
probe = self._exec(self._write_probe_cmd(path, sentinel, body_mode))
output = probe.stdout or ""
if sentinel not in output:
if _strip_terminal_fence_leaks(output).strip() == MISSING_SENTINEL:
ending = _detect_line_ending(pre_content) if pre_content else None
return False, pre_content, ending
logger.debug(
"write_file: pre-write probe reply for %s has no sentinel "
"(exit %s, %d chars); falling back to sequential probes",
path, probe.exit_code, len(output))
return self._probe_write_target_sequential(path, pre_content, want_pre)
segments = _split_segments(output, sentinel)
if probe.exit_code != 0 or len(segments) != 2:
logger.debug(
"write_file: pre-write probe for %s returned exit %s with %d "
"segments (want 2); falling back to sequential probes",
path, probe.exit_code, len(segments))
return self._probe_write_target_sequential(path, pre_content, want_pre)
head_seg, body = segments
head_bytes = self._decode_base64_sample(head_seg)
if head_bytes is None:
# No clean base64 on this shell; ask the way we used to.
logger.debug(
"write_file: no usable base64 head for %s; paying one extra "
"round-trip for the BOM probe", path)
has_bom = self._file_has_bom(path, pre_content)
else:
has_bom = head_bytes.startswith(_UTF8_BOM.encode("utf-8"))
if body_mode == "cat" and body:
pre_content = body
if pre_content:
ending = _detect_line_ending(pre_content)
elif body_mode == "sample" and body:
ending = _detect_line_ending(body)
else:
ending = None
return has_bom, pre_content, ending
def _probe_write_target_sequential(self, path: str, pre_content: Optional[str], want_pre: bool,
) -> tuple[bool, Optional[str], Optional[str]]:
"""Pre-compound form of ``_probe_write_target``: one exec per question. A
failed ``cat`` leaves pre_content None so the lint-delta and LSP consumers
degrade gracefully."""
if want_pre and pre_content is None:
read_result = self._cat(path)
if read_result.exit_code == 0 and read_result.stdout:
pre_content = read_result.stdout
if pre_content:
ending = _detect_line_ending(pre_content)
else:
head = self._head(path, 4096)
ending = _detect_line_ending(head.stdout) if head.exit_code == 0 and head.stdout else None
return self._file_has_bom(path, pre_content), pre_content, ending
def _verify_written_hash(self, path: str, content_bytes: bytes) -> tuple[Optional[bool], Optional[WriteResult]]:
"""Compare the on-disk sha256 to the intended bytes: ``(verified, error)``.
The explicit flag saves the model a confirming re-read; a mismatch is a hard
error. ``verified`` is None when the hash could not be taken."""
try:
hash_result = self._exec(f"sha256sum {self._escape_shell_arg(path)} 2>/dev/null")
if hash_result.exit_code == 0 and hash_result.stdout.strip():
disk_sha = hash_result.stdout.strip().split()[0]
if disk_sha != hashlib.sha256(content_bytes).hexdigest():
return False, WriteResult(error=(
f"Post-write verification failed for {path}: on-disk "
"content hash differs from the intended write. The "
"write did not persist correctly — re-read the file "
"and retry."))
return True, None
except Exception:
pass
return None, None
def write_file(self, path: str, content: str, pre_content: Optional[str] = None) -> WriteResult:
"""Write content atomically, creating parent directories as needed.
Order: deny list → lone-surrogate refusal → fail-closed syntax gate on the
CANDIDATE content (JSON/YAML/TOML) → one compound on-disk probe
(pre-content when wanted, CRLF, BOM; see ``_probe_write_target``) →
CRLF/BOM preservation → LSP baseline snapshot → atomic write (content rides
stdin: no ARG_MAX limit) → sha256 verification → lint delta → LSP
diagnostics when syntax is clean. ``pre_content``: pre-edit content the
caller already has (skips the read); BOM detection always probes disk.
"""
path = self._expand_path(path)
denied = get_write_denied_error(path)
if denied:
return WriteResult(error=denied)
refused = self._reject_unencodable(path, content)
if refused is not None:
return refused
ext = os.path.splitext(path)[1].lower()
refused = self._fail_closed_syntax_error(path, ext, content)
if refused is not None:
return refused
# Pre-content is read only for extensions in the UNION of in-process lint and
# LSP coverage (keeps the hot path fast for binaries).
want_pre = ext in LINTERS_INPROC or self._lsp_handles_extension(ext)
has_bom, pre_content, original_ending = self._probe_write_target(path, pre_content, want_pre)
# read_file strips the BOM and models send bare-LF text, so a round-trip would
# otherwise normalize CRLF files and drop the BOM (prepend only when absent).
if original_ending == "\r\n":
content = _normalize_line_endings(content, "\r\n")
if has_bom and not _has_bom(content):
content = _UTF8_BOM + content
# Best-effort snapshot so the LSP tier reports only this edit's diagnostics.
self._snapshot_lsp_baseline(path)
# ``dirs_created`` means "parent dirs ensured" (mkdir -p is folded into
# _atomic_write; its failure surfaces as the atomic-write error below).
dirs_created = bool(os.path.dirname(path))
# surrogateescape is the exact inverse of the decode that may have produced
# this content, so these are the bytes on disk; the early rejection above
# guarantees this cannot raise.
content_bytes = content.encode("utf-8", "surrogateescape")
write_result = self._atomic_write(path, content)
if write_result.exit_code != 0:
return WriteResult(error=f"Failed to write file: {write_result.stdout}")
content_verified, verify_error = self._verify_written_hash(path, content_bytes)
if verify_error is not None:
return verify_error
lint_result = self._check_lint_delta(path, pre_content=pre_content, post_content=content)
# LSP diagnostics are a separate channel, fired only when the syntax tier is
# clean (no point asking an LSP about a file that won't parse).
lsp_diagnostics: Optional[str] = None
if lint_result.success or lint_result.skipped:
lsp_diagnostics = self._maybe_lsp_diagnostics(path, pre_content=pre_content, post_content=content) or None
return WriteResult(
bytes_written=len(content_bytes), dirs_created=dirs_created, verified=content_verified,
_content_sha256=hashlib.sha256(content_bytes).hexdigest(),
lint=lint_result.to_dict() if lint_result else None, lsp_diagnostics=lsp_diagnostics)
# --- PATCH (replace mode) -----------------------------------------------
def _no_match_result(self, path: str, content: str, old_string: str,
new_string: str, match_count: int, error: Optional[str]) -> PatchResult:
"""PatchResult for a failed fuzzy match. Already-applied detection first: the
most common production failure is a re-send of an edit that already landed,
and a success-shaped no-op stops the model burning turns on re-reads.
Otherwise attach a best-effort "Did you mean?" snippet to the error."""
from tools.fuzzy_match import format_no_match_hint, is_already_applied
if is_already_applied(content, old_string, new_string):
return PatchResult(
success=True, no_change=True,
note=(
f"File already contains the target text — the edit "
f"appears to be already applied to {path}. No write "
"performed; do not re-send this patch."))
err_msg = error or f"Could not find match for old_string in {path}"
try:
err_msg += format_no_match_hint(err_msg, match_count, old_string, content)
except Exception:
pass
return PatchResult(error=err_msg)
def _verify_patch_persisted(self, path: str, new_content: str) -> Optional[PatchResult]:
"""Re-read ``path`` and confirm the intended bytes landed; error result or None.
Catches silent persistence failures (FS oddities, races, truncated pipe).
Line endings are normalized first (Windows text-mode ``open()`` writes LF as
CRLF) and the re-read's BOM stripped (``new_content`` is the BOM-less
string we matched against)."""
data, _failed = self._read_exact_bytes(path)
if data is None:
return PatchResult(error=f"Post-write verification failed: could not re-read {path}")
bomless, _ = _strip_bom(data.decode("utf-8", "surrogateescape"))
on_disk = bomless.replace("\r\n", "\n").replace("\r", "\n")
intended = new_content.replace("\r\n", "\n").replace("\r", "\n")
if on_disk != intended:
return PatchResult(error=(
f"Post-write verification failed for {path}: on-disk content "
f"differs from intended write "
f"(wrote {len(intended)} chars, read back "
f"{len(on_disk)} chars after normalizing line endings). "
"The patch did not persist. Re-read the file and try again."))
return None
def patch_replace(self, path: str, old_string: str, new_string: str,
replace_all: bool = False) -> PatchResult:
"""Replace text in a file using fuzzy matching (``old_string`` must be
unique unless ``replace_all``). Returns a PatchResult with diff + lint."""
path = self._expand_path(path)
denied = get_write_denied_error(path)
if denied:
return PatchResult(error=denied)
data, failed = self._read_exact_bytes(path)
if data is None:
return PatchResult(error=failed.cwd_error or f"Failed to read file: {path}")
# Every line the replacement does not touch is written back, so read the exact bytes;
# surrogateescape lets write_file restore any byte UTF-8 cannot decode (#79178).
# Match and diff on BOM-stripped content (a phantom U+FEFF defeats an exact
# first-line match); the raw read becomes write_file's pre_content.
raw_content = data.decode("utf-8", "surrogateescape")
content, _ = _strip_bom(raw_content)
from tools.fuzzy_match import fuzzy_find_and_replace
new_content, match_count, _strategy, error = fuzzy_find_and_replace(
content, old_string, new_string, replace_all)
if error or match_count == 0:
return self._no_match_result(path, content, old_string, new_string, match_count, error)
# Models send bare-LF old/new strings; normalize the substituted region to
# the file's ending so CRLF files stay consistent.
file_ending = _detect_line_ending(content)
if file_ending:
new_content = _normalize_line_endings(new_content, file_ending)
write_result = self.write_file(path, new_content, pre_content=raw_content)
if write_result.error:
return PatchResult(error=f"Failed to write changes: {write_result.error}")
verify_error = self._verify_patch_persisted(path, new_content)
if verify_error is not None:
return verify_error
lint_result = self._check_lint_delta(path, pre_content=content, post_content=new_content)
return PatchResult(
success=True, diff=self._unified_diff(content, new_content, path), files_modified=[path],
lint=lint_result.to_dict() if lint_result else None,
# From the internal write_file call, whose baseline was the pre-patch content.
lsp_diagnostics=write_result.lsp_diagnostics)
def patch_v4a(self, patch_content: str) -> PatchResult:
"""Apply a V4A format patch (``*** Begin Patch`` / ``*** Update File:`` /
``@@ hint @@`` hunks / ``*** End Patch``)."""
from tools.patch_parser import parse_v4a_patch, apply_v4a_operations
operations, parse_error = parse_v4a_patch(patch_content)
if parse_error:
return PatchResult(error=f"Failed to parse patch: {parse_error}")
return apply_v4a_operations(operations, self)
# --- SEARCH -------------------------------------------------------------
def search(self, pattern: str, path: str = ".", target: str = "content",
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
order: str = "discovery") -> SearchResult:
"""Search for content (regex, ``target="content"``) or files (glob,
``target="files"``). ``output_mode``: "content", "files_only" or "count";
``context``: lines of context around matches; ``order``: file-search
ordering — fast "discovery" or exact "modified" time."""
offset, limit = normalize_search_pagination(offset, limit)
if target == "files" and order not in {"discovery", "modified"}:
return SearchResult(
error=(f"Invalid file search order {order!r}; expected "
"'discovery' or 'modified'."))
path = self._expand_path(path)
probe = self._path_exists_probe(path)
exists_probe = probe.stdout
if probe.cwd_error:
return SearchResult(error=probe.cwd_error)
if "exists" not in exists_probe and "not_found" not in exists_probe:
return SearchResult(error=(f"Terminal environment unavailable: could not stat {path} "
"(the sandbox may still be starting or was removed). Retry shortly."))
if "not_found" in exists_probe:
# Models often pass several paths in one string: search the parts that exist.
multi = self._try_multi_path_search(
pattern, path, target, file_glob, limit, offset, output_mode, context, order)
if multi is not None:
return multi
return self._path_not_found_result(path)
result = self._dispatch_search(pattern, path, target, file_glob, limit, offset,
output_mode, context, order)
exclusions = self._macos_search_exclusions(path)
if exclusions and not result.error:
skipped = ", ".join(item.split("/")[-1] for item in exclusions)
result.warning = (
"Skipped macOS protected folders during broad search to avoid "
f"an unattended privacy prompt: {skipped}. Search a protected "
"folder directly when access is intentional.")
return result
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Any # noqa: F401,E402
from typing import ClassVar # noqa: F401,E402
from typing import List # noqa: F401,E402
from agent.file_safety import build_write_denied_paths # noqa: F401,E402
from agent.file_safety import build_write_denied_prefixes # noqa: F401,E402
from dataclasses import dataclass # noqa: F401,E402
from dataclasses import field # noqa: F401,E402
import posixpath # noqa: F401,E402
import threading # noqa: F401,E402
MAX_LINES = 2000
MAX_LINE_LENGTH = 2000
WRITE_DENIED_PATHS = build_write_denied_paths(_HOME)
WRITE_DENIED_PREFIXES = build_write_denied_prefixes(_HOME)
_PLUGIN_COMPAT_LAZY = {
'DEFAULT_READ_LIMIT': ('tools.file_operations_common', 'DEFAULT_READ_LIMIT'),
'DEFAULT_READ_OFFSET': ('tools.file_operations_common', 'DEFAULT_READ_OFFSET'),
'DEFAULT_SEARCH_LIMIT': ('tools.file_operations_common', 'DEFAULT_SEARCH_LIMIT'),
'DEFAULT_SEARCH_OFFSET': ('tools.file_operations_common', 'DEFAULT_SEARCH_OFFSET'),
'LINTERS': ('tools.file_operations_lint', 'LINTERS'),
'LintResult': ('tools.file_operations_common', 'LintResult'),
'MAX_FILE_SIZE': ('tools.transcription_common', 'MAX_FILE_SIZE'),
'SEARCH_PRUNE_DIR_NAMES': ('agent.search_policy', 'SEARCH_PRUNE_DIR_NAMES'),
'SearchMatch': ('tools.file_operations_common', 'SearchMatch'),
'build_write_denied_paths': ('agent.file_safety', 'build_write_denied_paths'),
'build_write_denied_prefixes': ('agent.file_safety', 'build_write_denied_prefixes'),
'tool_interrupt': ('tools', 'interrupt'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----