Files
hermes-agent/tools/file_tools_write_guards.py
kshitijk4poor 42841ece0f docs(file-tools): trim remote-binary state helper docstring to its contract
The helper docstring restated the inline why-comments (exact probe string,
backend $HOME tilde fallback, which statuses prove absence). Keep only the
contract callers need: three return values and that anything not proven
absent is "unavailable" and must fail closed. Inline comments stay.
2026-09-26 22:26:05 +05:30

628 lines
31 KiB
Python

"""Write-side safety guards for write_file / patch.
Every guard returns ``None`` when the write may proceed, else an error string
the tool returns verbatim.
Guards, in the order the tools apply them: ``_check_sensitive_path`` (hard
deny), ``_check_binary_document_write``, ``_check_protected_instruction_write``
(ALWAYS ask), ``_check_approval_required_write`` (normal gate),
``_check_cross_profile_path`` (sandbox-mirror lost-work), ``_is_internal_file_tool_content``.
``_stale_overwrite_blocker`` (write_file only, under the per-path lock) refuses a
whole-file overwrite of content this task never saw or that changed since.
"""
import fnmatch
import os
from pathlib import Path
from agent.file_safety import get_nt_namespace_error
from tools import file_state
from tools.binary_extensions import (
has_binary_extension,
has_opaque_document_extension,
is_pdf_path,
is_sqlite_sidecar,
)
from tools.file_tools_paths import (
_expand_tilde, _resolve_path_for_task, _ssh_path_escapes_home, _terminal_env_type_for_task)
from tools.file_tools_read_tracking import _has_full_write_baseline, _read_mtime_drifted
# Prefixes matched after realpath. macOS: /private/var mirrors /var — block the
# sensitive subtrees only; a blanket "/private/var/" refuses every temp-file
# write because $TMPDIR, /tmp and /var/folders all realpath there.
_SENSITIVE_PATH_PREFIXES = (
"/etc/", "/boot/", "/usr/lib/systemd/",
"/private/etc/",
"/private/var/db/", "/private/var/root/")
_SENSITIVE_EXACT_PATHS = {"/var/run/docker.sock", "/run/docker.sock"}
# NOTE: these four are a TEST-OVERRIDE surface, not a process cache. Both getters
# resolve per call in production (below) because ``get_hermes_home()`` /
# ``get_config_path()`` are per-turn contextvar-scoped: a multiplexed gateway
# (``gateway.multiplex_profiles: true``) serves many profiles in one process, and
# a process-wide memo would freeze whichever profile's home/config ran first —
# making the protected-instruction gate and the ``config.yaml`` hard-block
# order-dependent, up to letting a later profile rewrite its own ``config.yaml``
# the block exists to protect (#107327). A test can still pin a value by setting
# the slot and its ``_loaded`` flag.
_hermes_config_resolved: str | None = None
_hermes_config_resolved_loaded = False
_real_hermes_home_cached: str | None = None
_real_hermes_home_loaded = False
def _config_path_resolved() -> str:
from hermes_cli.config import get_config_path
return str(get_config_path().resolve())
def _hermes_home_real() -> str:
from hermes_constants import get_hermes_home
return os.path.realpath(str(get_hermes_home()))
def _get_hermes_config_resolved() -> str | None:
"""Resolved absolute path of the Hermes config file for the ACTIVE profile.
Resolved per call so it tracks the per-turn ``HERMES_HOME`` scope (#107327);
a test may pin it via ``_hermes_config_resolved`` + ``_hermes_config_resolved_loaded``."""
if _hermes_config_resolved_loaded:
return _hermes_config_resolved
try:
return _config_path_resolved()
except Exception:
# Resolver failure must stay bound to the ACTIVE profile's home, not the
# subprocess HOME. ``_expand_tilde("~/...")`` follows the subprocess-HOME
# contract, which under host ``auto`` mode can be the real/default user
# home rather than the active multiplex ``HERMES_HOME`` — comparing
# beta's ``config.yaml`` against the default/root config would let the
# hard-block fail open on the exception path. Re-derive from the same
# ``get_hermes_home()`` key the happy path uses, and substitute no
# unrelated home if even that is gone (#107327 follow-up; PR #107335).
try:
from hermes_constants import get_hermes_home
return str((Path(str(get_hermes_home())) / "config.yaml").resolve())
except Exception:
return None
def _get_real_hermes_home() -> str | None:
"""Realpath of the authoritative Hermes home for the ACTIVE profile.
Resolved per call so it tracks the per-turn ``HERMES_HOME`` scope (#107327);
a test may pin it via ``_real_hermes_home_cached`` + ``_real_hermes_home_loaded``.
Consumers exempting a whole TREE want ``_hermes_exempt_homes()``: under a named
profile this home is ``<root>/profiles/<name>`` and the root is exempt too."""
if _real_hermes_home_loaded:
return _real_hermes_home_cached
try:
return _hermes_home_real()
except Exception:
# Same active-profile binding on the exception path (see
# ``_get_hermes_config_resolved``): re-derive from ``get_hermes_home()``
# rather than ``_expand_tilde("~/.hermes")`` so the protected-instruction
# exemption resolves against the active profile — not the subprocess /
# default home — and substitute no unrelated home if the active security
# path cannot be established (PR #107335). A ``None`` here fails closed at
# the consumer: the ``~/.hermes`` exemption is skipped, so the gate runs.
try:
from hermes_constants import get_hermes_home
return os.path.realpath(str(get_hermes_home()))
except Exception:
return None
def _hermes_exempt_homes() -> tuple[str, ...]:
"""Realpaths of the Hermes home tree(s) the protected-instruction gate must stay out of:
the ACTIVE profile's home, plus the Hermes ROOT when that home is a named profile
(``<root>/profiles/<name>``). Exempting only the profile dir left the root's DIRECT files
(LEDGER.md / MEMORY.md / SOUL.md / AGENTS.md ...) to the ``.hermes`` component rule, which
gated them like a project-local ``<repo>/.hermes/config.yaml`` — fail-closed headless
(#110630). They are the agent's own store, governed by their own guards, exactly like
``~/.hermes`` under the default profile. The root is added only when the shape really is a
named profile (``named_profile_home``), so a coincidental ``profiles/`` dir elsewhere never
exempts its parent; the home comes from the ACTIVE scope, never ``HERMES_HOME`` alone."""
home = _get_real_hermes_home()
if not home:
return ()
try:
from hermes_constants import named_profile_home
profile_home = named_profile_home(home)
except Exception:
profile_home = None
if profile_home is None:
return (home,)
root = os.path.realpath(str(Path(str(profile_home)).parent.parent))
return (home, root) if root and root != home else (home,)
def _resolved_or_raw(filepath: str, task_id: str) -> str:
"""Task-resolved path string, falling back to the raw input on resolution failure."""
try:
return str(_resolve_path_for_task(filepath, task_id))
except (OSError, ValueError):
return filepath
def _check_sensitive_path(filepath: str, task_id: str = "default") -> str | None:
"""Return an error message if the path targets a sensitive system location."""
# NT/device-namespace guard on the RAW string, BEFORE the task-base join:
# on POSIX a leading "\??\" reads as a relative segment and gets anchored
# under the base dir, hiding the prefix from the resolved-path checks,
# while the same string relayed to a Windows host (remote backend, desktop
# bridge) triggers the NTLM-leak vector. See agent/file_safety.py.
nt_err = get_nt_namespace_error(filepath, verb="Write")
if nt_err:
return nt_err
candidates = (_resolved_or_raw(filepath, task_id), os.path.normpath(_expand_tilde(filepath)))
if _ssh_path_escapes_home(candidates[0]) and _terminal_env_type_for_task(task_id) == "ssh":
return (
f"Refusing to write to {filepath}: it climbs above the SSH home and the remote "
"home could not be detected, so its target cannot be checked. Pass an absolute path.")
if any(c.startswith(_SENSITIVE_PATH_PREFIXES) or c in _SENSITIVE_EXACT_PATHS for c in candidates):
return (
f"Refusing to write to sensitive system path: {filepath}\n"
"Use the terminal tool with sudo if you need to modify system files.")
# approvals.mode and other security settings live in config.yaml; a
# prompt-injected agent could silently disable exec approval by editing it.
hermes_config = _get_hermes_config_resolved()
if hermes_config and hermes_config in candidates:
return (
f"Refusing to write to Hermes config file: {filepath}\n"
"Agent cannot modify security-sensitive configuration. "
"Edit ~/.hermes/config.yaml directly or use 'hermes config' instead.")
return None
# ── Protected agent-instruction files (always-ask approval gate) ─────────
# Files that steer FUTURE agent behavior are a prompt-injection persistence
# vector (AGENTS.md / CLAUDE.md / SOUL.md / .cursorrules / project .hermes tree).
# Writes ALWAYS require human approval — even under --yolo — and fail closed
# without a human channel. Basenames match in ANY directory, case-insensitively.
# Ported from: RooCodeInc/Roo-Code RooProtectedController (Apache-2.0). Companion: the terminal-tool vector
# is covered separately (#58631); this gate covers the write_file/patch vector. Symlink lesson from #41351:
# always realpath before matching. Scope decision (documented): basenames match in ANY directory, because
# project-context instruction files are loaded from cwd trees — an AGENTS.md anywhere the agent might later
# run from is a live target. Basenames match case-insensitively so case-variant spellings on
# case-insensitive filesystems (macOS/Windows) cannot slip past; on case-sensitive filesystems most loaders
# probe common case variants too, so the stricter behavior is kept uniform.
_PROTECTED_INSTRUCTION_BASENAMES = frozenset({
"agents.md", "claude.md", "soul.md", ".cursorrules"})
def _protected_instruction_config() -> tuple[bool, list[str]]:
"""Return ``(enabled, extra_patterns)`` from ``security.protected_instruction_files`` /
``security.protected_instruction_extra_patterns`` (fnmatch on basename). Config read
failures keep the gate ON — fail-safe for a security boundary."""
try:
from hermes_cli.config import load_config, cfg_get
cfg = load_config()
enabled = cfg_get(cfg, "security", "protected_instruction_files", default=True)
extra = cfg_get(cfg, "security", "protected_instruction_extra_patterns", default=[])
except Exception:
return True, []
if not isinstance(enabled, bool):
enabled = True
if not isinstance(extra, list):
extra = []
return enabled, [str(p) for p in extra if p]
def _protected_instruction_reason(filepath: str, task_id: str = "default",
*, enabled: bool | None = None,
extra_patterns: list[str] | None = None) -> str | None:
"""Return a short label when ``filepath`` targets a protected instruction file, else ``None``.
Matches BOTH the normalized input and its realpath so no symlink direction escapes.
Matching runs on BOTH the normalized input path and its realpath so neither a symlink pointing AT a
protected file (#41351) nor a protected name that is itself a symlink escapes the gate. ``..`` traversal
is neutralized by normpath/realpath before the basename compare.
"""
if enabled is None or extra_patterns is None:
enabled, extra_patterns = _protected_instruction_config()
if not enabled:
return None
normalized = os.path.normpath(_expand_tilde(filepath))
try:
resolved = os.path.realpath(str(_resolve_path_for_task(filepath, task_id)))
except (OSError, ValueError, RuntimeError):
resolved = os.path.realpath(normalized)
# ~/.hermes itself is governed by its own guards (config.yaml hard-block,
# mirror guard, write_approval); this gate targets PROJECT-LOCAL files only.
# Must run before the ``.hermes`` component rule, which would match the home.
# ``_hermes_exempt_homes`` also covers the ROOT when the active home is a named
# profile, so ~/.hermes/<file> cannot read as project-local ``.hermes`` config.
for real_home in _hermes_exempt_homes():
if resolved == real_home or resolved.startswith(real_home + os.sep):
return None
for candidate in (normalized, resolved):
base = os.path.basename(candidate)
base_lower = base.lower()
if base_lower in _PROTECTED_INSTRUCTION_BASENAMES or any(
fnmatch.fnmatch(base_lower, pattern.lower()) for pattern in extra_patterns):
return base
# Project-local .hermes config dirs (<repo>/.hermes/config.yaml) steer
# behavior too. Only the IMMEDIATE parent counts — matching any ancestor
# would gate every write inside a checkout living under ~/.hermes.
parts = candidate.replace("\\", "/").rstrip("/").split("/")
if len(parts) >= 2 and parts[-2] == ".hermes":
return candidate
return None
_APPROVAL_UNAVAILABLE = "requires approval but the approval subsystem is unavailable."
_NO_HUMAN = "requires approval but no interactive user or gateway is present to approve it."
def _request_protected_instruction_approval(reasons: list[str], task_id: str = "default") -> str | None:
"""Ask the human to approve a write to protected instruction file(s); ``None`` when approved.
Deliberately NOT routed through ``_run_approval_gate`` (honors --yolo and
allowlists): this gate is one-operation approval EVERY time, no persisted
scope, fail-closed without a human channel.
"""
targets = ", ".join(dict.fromkeys(reasons))
description = (
f"Write to protected agent-instruction file(s): {targets}. "
"These files steer future agent behavior; approval is always "
"required (not bypassed by auto-approve).")
display = f"<write to {targets}>"
blocked = (
f"BLOCKED: write to protected agent-instruction file(s) ({targets}) "
"{why} The user has NOT consented to this write. Do NOT retry it or "
"attempt the same edit via another path (terminal, execute_code, "
"etc.).")
timed_out = blocked.format(why="approval prompt timed out without a user response. Silence is not consent.")
denied = blocked.format(why="was denied by the user.")
try:
import tools.approval as _approval
from tools.approval_context import get_current_session_key
from tools.approval_gateway_wait import _await_gateway_decision
from tools.approval_prompt import prompt_dangerous_approval
except Exception:
return blocked.format(why=_APPROVAL_UNAVAILABLE)
# Gateway surface: block on the button round-trip when a notify callback
# is registered for this session. One-operation only — no scope buttons.
session_key = get_current_session_key()
try:
with _approval._lock:
notify_cb = _approval._gateway_notify_cbs.get(session_key)
except Exception:
notify_cb = None
if notify_cb is not None:
approval_data = {
"command": display,
"pattern_key": "protected_instruction_file",
"pattern_keys": ["protected_instruction_file"],
"description": description,
"allow_permanent": False,
"allow_session": False}
decision = _await_gateway_decision(session_key, notify_cb, approval_data, surface="gateway")
if decision.get("notify_failed"):
return blocked.format(why="requires approval but the approval request could not be delivered.")
if decision.get("cancelled"):
return blocked.format(why=f"approval was withdrawn before the user answered ({decision['cancelled']}).")
choice, timed = decision.get("choice"), not decision.get("resolved")
else:
# CLI surface: per-thread approval callback (prompt_toolkit panel).
try:
from tools.terminal_tool import _get_approval_callback
callback = _get_approval_callback()
except Exception:
callback = None
if callback is None:
# No human channel (script, cron, background thread): fail closed —
# auto-approving here would recreate the persistence vector.
return blocked.format(why=_NO_HUMAN)
choice = prompt_dangerous_approval(
display, description, allow_permanent=False, allow_session=False, approval_callback=callback)
if choice == "cancelled":
return blocked.format(why="approval prompt could not be delivered or was not answered "
f"({getattr(choice, 'cause', 'no answer')}).")
timed = choice == "timeout"
# Any tapped scope is a one-operation grant; nothing is persisted.
if not timed and choice in {"once", "session", "always"}:
return None
return timed_out if timed else denied
def _check_protected_instruction_write(paths: list[str], task_id: str = "default") -> str | None:
"""Gate a write/patch touching protected instruction files. ONE protected file gates
the ENTIRE multi-file patch (one prompt, all-or-nothing)."""
enabled, extra = _protected_instruction_config()
if not enabled:
return None
reasons = [r for r in (_protected_instruction_reason(p, task_id, enabled=enabled, extra_patterns=extra)
for p in paths) if r]
if not reasons:
return None
return _request_protected_instruction_approval(reasons, task_id)
def _check_approval_required_write(paths: list[str], task_id: str = "default") -> str | None:
"""Gate a write/patch touching an approval-required path (``~/.ssh/config`` can steer
execution via ``ProxyCommand``). Routine gate: once/session/always, honors --yolo,
fail-closed without an interactive/gateway channel."""
try:
from agent.file_safety import is_write_approval_required
except Exception:
return None
targets = [p for p in paths if is_write_approval_required(p)]
if not targets:
return None
display_targets = ", ".join(dict.fromkeys(targets))
description = (
f"Write to SSH client config file(s): {display_targets}. "
"The SSH config can carry ProxyCommand / Match exec directives that "
"run commands, so writes require your approval.")
blocked = (
f"BLOCKED: write to SSH config file(s) ({display_targets}) "
"{why} Do NOT retry it via another path (terminal, execute_code) "
"without the user's explicit consent.")
try:
import tools.approval as _approval
except Exception:
return blocked.format(why=_APPROVAL_UNAVAILABLE)
result = _approval._run_approval_gate(
pattern_key="ssh_config_write",
description=description,
display_target=f"<write to {display_targets}>",
cron_deny_message=blocked.format(why="requires approval but this cron session denies it."),
single_query_deny_message=blocked.format(
why="requires approval but single-query (-q) sessions run "
"without a user present to approve it. To allow flagged "
"actions in single-query mode, set approvals.single_query_mode: "
"approve in config.yaml."),
autoapprove_log_prefix="ssh_config_write",
fail_closed_when_no_human=True,
no_human_block_message=blocked.format(why=_NO_HUMAN))
if result.get("approved"):
return None
return result.get("message") or blocked.format(why="was denied.")
def _get_container_mirror_prefix_for_task(task_id: str = "default") -> str | None:
"""Return the container-side Hermes mirror prefix for persistent Docker file tools."""
try:
from tools.terminal_tool import (
_active_environments, _env_lock, _get_env_config, _resolve_container_task_id)
container_key = _resolve_container_task_id(task_id)
with _env_lock:
env = _active_environments.get(container_key) or _active_environments.get(task_id)
if env is not None:
persistent_docker = (env.__class__.__name__ == "DockerEnvironment"
and bool(getattr(env, "_persistent", False)))
return "/root/.hermes" if persistent_docker else None
config = _get_env_config()
except Exception:
return None
if config.get("env_type") == "docker" and config.get("container_persistent", True):
return "/root/.hermes"
return None
def _check_cross_profile_path(filepath: str, task_id: str = "default") -> str | None:
"""Soft-guard: warn when ``filepath`` lands on a host-side or Docker sandbox MIRROR of
Hermes state (a write the host never reads). Not profile isolation — that guard was
removed; ``cross_profile=True`` keeps bypassing this one for replay compat. Fails open."""
try:
from agent.file_safety import get_container_mirror_warning, get_sandbox_mirror_warning
except Exception:
return None
resolved = _resolved_or_raw(filepath, task_id)
warning = get_sandbox_mirror_warning(resolved)
if warning is not None:
return warning
return get_container_mirror_warning(resolved, mirror_prefix=_get_container_mirror_prefix_for_task(task_id))
def _target_regular_file_state(filepath: str, task_id: str = "default") -> str:
"""Is a REGULAR file at *filepath* present where the write will execute
(the task's backend, not the controller's disk — #122662)?
Returns ``"exists"``, ``"absent"`` or ``"unavailable"``. Host-backed envs
keep ``Path.is_file`` semantics; anything not proven absent is
``"unavailable"`` and callers must fail closed.
"""
from tools.file_tools import _file_ops_uses_host_paths, _get_file_ops
try:
resolved = str(_resolve_path_for_task(filepath, task_id))
except Exception:
resolved = None
try:
file_ops = _get_file_ops(task_id)
except Exception:
return "unavailable"
if _file_ops_uses_host_paths(file_ops):
# Host writes land on the resolved path, else today's HOST
# ``_expand_tilde`` fallback — probe exactly that string.
probe = _expand_tilde(filepath) if resolved is None else resolved
try:
return "exists" if Path(probe).is_file() else "absent"
except OSError:
return "absent"
try:
# Backend writes land on ``_expand_path(_resolved or path)``: the
# backend's own home for a tilde fallback, never the host's.
_size, status = file_ops._probe_regular_file(file_ops._expand_path(resolved or filepath))
except Exception:
return "unavailable"
if status in ("ok", "bad_size"):
# bad_size: ``[ -f ]`` succeeded, only ``wc`` was unparseable.
return "exists"
if status in ("missing", "not_regular"):
# not_regular: no REGULAR file at the path (dir/FIFO/dangling link) —
# the same answer Path.is_file gives on the host.
return "absent"
return "unavailable"
def _check_binary_document_write(filepath: str, task_id: str = "default") -> str | None:
"""Reject text-tool writes that would corrupt a binary document (read_file showed
EXTRACTED text, so the model may write it back). Opaque document formats and
SQLite sidecars (-wal/-shm/-journal) are always rejected; .pdf and every other
BINARY_EXTENSIONS suffix only when OVERWRITING an existing file (raw PDF syntax
is text-authorable and text fixtures named ``*.db`` exist). "Existing" is asked
of the filesystem the write will hit — the task's backend, via
``_target_regular_file_state`` — never the controller's disk alone (#122662).
``read_file`` auto-extracts .docx/.xlsx/.pptx (and PDF, via anydoc) to readable text, so the model
plausibly believes it holds the file's contents and tries to write the edited text back with
write_file/patch. A plain-text write can never produce a valid OOXML/OLE/ODF container, so that write
silently destroys the document (port of nearai/ironclaw#7109).
"""
ext = os.path.splitext(filepath)[1].lower()
if has_opaque_document_extension(filepath):
return (
f"Refusing to write plain text to binary document '{filepath}' ({ext}). "
"A text write cannot produce a valid document container and would "
"corrupt the file (read_file showed you EXTRACTED text, not the real "
"bytes). Use the docx/xlsx/powerpoint skills or a library like "
"python-docx/openpyxl/python-pptx via the terminal to create or edit "
"this document.")
# A -wal/-shm/-journal path is never a legitimate text target, even when
# no sidecar exists yet: a checkpointed db has none on disk, and a garbage
# WAL dropped next to a live database is picked up on the next open.
if is_sqlite_sidecar(filepath):
return (
f"Refusing to write plain text to binary SQLite sidecar '{filepath}' ({ext}). "
"A -wal/-shm/-journal file holds raw database pages that SQLite "
"reads on the next open; text there corrupts the database. Use the "
"sqlite3 CLI or a SQLite library via the terminal to modify the "
"database instead.")
# Overwriting an existing binary (PDF, image, archive, SQLite db, ...)
# with text destroys it — the model only ever saw extracted or mojibake
# text. Creating a NEW file with such an extension stays allowed: raw PDF
# syntax is text-authorable and text fixtures named ``*.db`` exist.
pdf = is_pdf_path(filepath)
if pdf or has_binary_extension(filepath):
state = _target_regular_file_state(filepath, task_id)
if state == "exists":
if pdf:
return (
f"Refusing to overwrite existing PDF '{filepath}' with plain text. "
"read_file showed you EXTRACTED text, not the real bytes — writing "
"text back would destroy the document. Use the pdf skill or a PDF "
"library via the terminal to modify it. (Creating a NEW .pdf file "
"is allowed.)")
return (
f"Refusing to overwrite existing binary file '{filepath}' ({ext}) "
"with plain text — read_file showed you extracted or mojibake "
"text, not the real bytes, and writing text back would destroy "
"the file. Use a binary-aware tool via the terminal to modify it "
"(for SQLite databases, the sqlite3 CLI or a SQLite library). "
"(Creating a NEW file with this extension is allowed.)")
if state == "unavailable":
# Fail closed: absence not proven on the filesystem the write would
# hit, so proceeding could destroy a binary the guard never saw.
return (
f"Refusing to write to '{filepath}': could not establish whether "
"the target file already exists where this write would execute "
"(the terminal environment may be starting, unreachable, or was "
"removed). The file was NOT modified — retry once the environment "
"is reachable.")
return None
# ── Internal display text must never be persisted as file content ────────
_READ_DEDUP_STATUS_MESSAGE = (
"File unchanged since last read. The content from "
"the earlier read_file result in this conversation is "
"still current — refer to that instead of re-reading.")
def _stale_overwrite_blocker(filepath: str, resolved: str | None, task_id: str) -> str | None:
"""Reason write_file must NOT replace the existing file, else ``None``.
Refuses BEFORE any disk mutation (the pre-#65604 warning arrived after the
clobber): a sibling/external/partial-read staleness finding, or an existing
file with no full-content baseline for this task (never read in full, read
redacted, only patched). Net-new files, files this task fully read (in one
page or by paging contiguously to the last line) or wrote, unresolvable
paths and the file-state kill switch all let the write proceed.
"""
if file_state.guard_disabled():
return None
stale = file_state.check_stale(task_id, resolved) if resolved else None
if stale:
return stale
if _read_mtime_drifted(filepath, task_id):
return (
f"{filepath} was modified since you last read it (external edit or "
"concurrent agent). Re-read the file before writing.")
if not resolved or _has_full_write_baseline(resolved, task_id):
return None
try:
exists = Path(resolved).exists()
except OSError:
return None
if not exists:
return None
return (
f"{resolved} exists but this task has not seen its full current content "
"(never read, only patched, or only a redacted/partial view). Read the "
"file — every page of it, if it needs offset/limit — or use patch for a "
"targeted edit; a stale conversation copy must not overwrite the current "
"disk content.")
def _stale_write_refusal(filepath: str, reason: str, resolved: str | None = None) -> dict:
"""Model-facing refusal payload for write_file; ``stale_write_blocked`` lets
callers tell it apart from I/O errors."""
result = {
"error": (
f"Refusing to overwrite {filepath}: {reason} "
"The file was NOT modified. Reload the current contents with read_file "
"(every page, for a file that needs offset/limit), merge the requested "
"change, then call write_file again. For small edits, prefer patch so "
"existing unrelated changes are preserved."),
"stale_write_blocked": True,
"path": filepath,
}
if resolved:
result["resolved_path"] = resolved
return result
def _is_internal_file_status_text(content: str) -> bool:
"""True when content is the read_file dedup status message, verbatim or lightly framed
(contains the full message and is <=2x its length — a real file quoting it would be longer)."""
if not isinstance(content, str):
return False
stripped = content.strip()
return bool(stripped) and _READ_DEDUP_STATUS_MESSAGE in stripped and (
len(stripped) <= 2 * len(_READ_DEDUP_STATUS_MESSAGE))
def _looks_like_read_file_line_numbered_content(content: str) -> bool:
"""True for content dominated by read_file's ``LINE_NUM|CONTENT`` display (>=60% of
non-empty lines are consecutive numbered lines; a lone ``1|value`` is allowed)."""
if not isinstance(content, str):
return False
lines = [line for line in content.splitlines() if line.strip()]
if len(lines) < 2:
return False
numbered: list[int] = []
for line in lines:
prefix, sep, _rest = line.lstrip().partition("|")
if sep and prefix.isdigit():
numbered.append(int(prefix))
if len(numbered) < 2 or len(numbered) / len(lines) < 0.6:
return False
consecutive_pairs = sum(1 for prev, current in zip(numbered, numbered[1:]) if current == prev + 1)
return consecutive_pairs >= len(numbered) - 1
def _is_internal_file_tool_content(content: str) -> bool:
"""Return True when content is file-tool display text, not intended file bytes."""
return _is_internal_file_status_text(content) or _looks_like_read_file_line_numbered_content(content)