Files
hermes-agent/agent/runtime_self_protection.py

291 lines
11 KiB
Python

"""The running Hermes runtime's own interpreter/venv is not agent-deletable.
Deleting the Python interpreter the current process boots from — or the base
interpreter its venv depends on — bricks the install: the next boot fails with
``uv trampoline failed to spawn Python child process`` and no amount of agent
work can repair it, because the agent itself no longer starts (#58748). The
same applies to overwriting the interpreter through the file tools.
Two questions, one coordinate system:
* :func:`command_deletes_runtime` — would a shell command delete a protected
path? Wired into the approval floor (``tools.approval._floor_block``), so it
cannot be bypassed by yolo / approvals.mode=off / cron approve mode.
* :func:`is_protected_path` — is a filesystem path protected? Wired into the
file-safety write classifier, covering write/patch/move/delete.
Protected set (all resolved through the same normalizer so shell spellings —
native, git-bash ``/c/...``, WSL ``/mnt/c/...``, ``$HOME`` — compare equal):
* ``sys.executable`` and ``sys.prefix`` (the runtime's own venv),
* the base interpreter from ``pyvenv.cfg``'s ``home =`` / ``sys._base_executable``,
* the uv-managed install directory holding that base (matched by version for
``uv python uninstall``).
Deliberately NOT protected: any other venv or interpreter on the machine — the
agent may freely manage project environments. Only the runtime it is itself
running from is off-limits.
"""
from __future__ import annotations
import os
import re
import shlex
import sys
from functools import lru_cache
from pathlib import Path
from typing import Optional
# Split a command line into individually-analyzable commands. Newlines, `;`,
# `&&`, `||` and `|` each start a fresh command whose own name decides whether
# it deletes anything.
_SEGMENT_SPLIT_RE = re.compile(r"\n|&&|\|\||[;|]")
# Words whose argument tail is a *different* command (sudo rm ...) or an
# environment assignment prefix (FOO=1 rm ...).
_COMMAND_PREFIXES = frozenset({"sudo", "command", "nohup", "exec", "env", "nice", "time"})
# Direct-deletion command names (basename, lowercase). `rm` covers POSIX and
# git-bash; the Windows-native spellings cover cmd (`rd`, `del`, `rmdir /s`)
# and PowerShell (`Remove-Item`, and its `ri`/`rm`/`del`/`erase` aliases).
_DELETING_COMMANDS = frozenset({
"rm", "rmdir", "rd", "del", "erase", "remove-item", "ri",
})
# `find <roots...> -delete` (or `-exec rm ...`) deletes its search roots.
_FIND_DELETE_RE = re.compile(r"(?:^|\s)-(?:delete|exec\s+rm\b)", re.IGNORECASE)
# uv-managed install layout: .../uv/python/cpython-3.14.7-arm64-apple-darwin/...
_UV_INSTALL_DIR_RE = re.compile(
r"(?i)(?P<root>.*[/\\](?:uv[/\\])?python[/\\]cpython-(?P<version>\d+(?:\.\d+)*)(?:[-_][^/\\]*)?)(?:[/\\].*)?$"
)
_WIN_DRIVE_FROM_POSIX_RE = re.compile(r"^/(?:(mnt)/)?([a-zA-Z])/(.+)$")
def _normalize_path(raw: str) -> str:
"""One canonical form for both shell spellings and runtime paths.
Expands ``~``/``$HOME``, maps git-bash (``/c/...``) and WSL (``/mnt/c/...``)
drive forms to native Windows paths, then ``realpath``s so symlink mirrors
(macOS ``/private``) and relocated installs compare equal. Missing tails are
fine: POSIX ``realpath`` resolves the existing prefix lexically.
"""
path = str(raw or "").strip()
if not path:
return ""
if len(path) >= 2 and path[0] == path[-1] and path[0] in "\"'":
path = path[1:-1]
try:
path = os.path.expandvars(os.path.expanduser(path))
except Exception:
try:
path = os.path.expanduser(path)
except Exception:
pass
if os.name == "nt":
m = _WIN_DRIVE_FROM_POSIX_RE.match(path)
if m:
path = f"{m.group(2)}:\\{m.group(3)}"
path = path.replace("/", "\\")
try:
return os.path.normcase(os.path.realpath(os.path.normpath(path)))
except Exception:
return os.path.normcase(os.path.normpath(path))
def _pyvenv_home(prefix: str) -> str:
"""The ``home =`` base-interpreter directory from ``<prefix>/pyvenv.cfg``."""
try:
lines = (Path(prefix) / "pyvenv.cfg").read_text(encoding="utf-8-sig", errors="replace").splitlines()
except Exception:
return ""
for line in lines:
if "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip().lower() == "home":
return value.strip()
return ""
@lru_cache(maxsize=8)
def _protected_snapshot(executable: str, prefix: str) -> tuple[tuple[str, str], ...]:
"""Resolved ``(path, description)`` pairs for the runtime's own interpreter.
Cached per (executable, prefix) — the pair cannot change while a process
runs, and keying on the inputs lets tests swap ``sys`` attributes freely.
"""
entries: list[tuple[str, str]] = []
exe = _normalize_path(executable)
if exe:
entries.append((exe, "the Python interpreter this Hermes runtime is running from"))
venv = _normalize_path(prefix)
if venv and venv != exe:
entries.append((venv, "this Hermes runtime's own virtualenv"))
base_dir = _normalize_path(_pyvenv_home(prefix)) or _normalize_path(getattr(sys, "_base_executable", "") or "")
if base_dir:
entries.append((base_dir, "the base interpreter this Hermes venv depends on"))
m = _UV_INSTALL_DIR_RE.match(base_dir.replace("\\", "/"))
if m:
uv_root = _normalize_path(m.group("root"))
if uv_root:
entries.append((uv_root, f"the uv-managed Python install this Hermes venv depends on ({m.group('version')})"))
return tuple((path, desc) for path, desc in entries if path)
def _protected() -> tuple[tuple[str, str], ...]:
return _protected_snapshot(getattr(sys, "executable", "") or "", getattr(sys, "prefix", "") or "")
def _overlaps(a: str, b: str) -> bool:
"""True when ``a`` and ``b`` are the same path or one is an ancestor of the other."""
if not a or not b:
return False
sep = os.sep
return a == b or a.startswith(b + sep) or b.startswith(a + sep)
def split_entry(path: str) -> tuple[str, str]:
"""``os.path.split`` for a directory entry, trailing separators and ``.`` components
dropped first: an empty or ``.`` leaf (``dir/link/``, ``dir/link/.``) would make entry
checks degenerate to the link's target, while pathlib/rm still act on ``link``.
A bare root (``/``, ``C:\\``) is kept as is."""
drive, tail = os.path.splitdrive(path)
parent, leaf = os.path.split(drive + (tail.rstrip(os.sep + (os.altsep or "")) or tail[:1]))
if leaf == "." and parent:
return split_entry(parent)
return parent, leaf
def is_protected_path(path: str, *, follow: bool = True) -> Optional[str]:
"""Description of the protected runtime path ``path`` touches, else ``None``.
``follow=False`` keeps the final component unresolved (the entry itself, for
ops that unlink/rename a symlink rather than its target)."""
if follow:
resolved = _normalize_path(path)
else:
parent, leaf = split_entry(path)
resolved = _normalize_path(parent or ".")
resolved = resolved and os.path.normcase(os.path.join(resolved, leaf))
if not resolved:
return None
for protected, description in _protected():
if _overlaps(resolved, protected):
return description
return None
def _split_words(segment: str) -> list[str]:
try:
return shlex.split(segment)
except ValueError:
return segment.split()
def _strip_prefixes(words: list[str]) -> list[str]:
out = list(words)
while out:
first = out[0]
if first in _COMMAND_PREFIXES:
out = out[1:]
continue
if "=" in first and not first.startswith(("-", "/")) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", first):
out = out[1:]
continue
break
return out
def _version_spec_matches(spec: str, version: str, uv_root_name: str) -> bool:
"""``uv python uninstall`` spec against the running base's version/dir."""
spec = spec.strip().lower()
if not spec:
return False
if spec in ("--all", "-a", "all"):
return True
if spec.startswith("-"):
return False
if uv_root_name.lower().startswith(spec):
return True
return spec == version or (version.startswith(spec) and version[len(spec):len(spec) + 1] == ".")
def _uv_uninstall_target(words: list[str]) -> Optional[str]:
"""Description when ``uv python uninstall`` would remove the running base."""
protected = _protected()
uv_entries = [
(root, desc) for root, desc in protected
if desc.startswith("the uv-managed Python install")
]
if not uv_entries:
return None
lowered = [w.lower() for w in words]
try:
python_at = lowered.index("python")
uninstall_at = lowered.index("uninstall", python_at + 1)
except ValueError:
return None
specs = words[uninstall_at + 1:]
for root, description in uv_entries:
m = _UV_INSTALL_DIR_RE.match(root.replace("\\", "/"))
version = m.group("version") if m else ""
root_name = os.path.basename(root)
if any(_version_spec_matches(spec, version, root_name) for spec in specs):
return description
return None
def command_deletes_runtime(command: str) -> Optional[str]:
"""Description of the runtime path ``command`` would delete, else ``None``.
Covers ``rm``/``rmdir``/``rd``/``del``/``erase``/``Remove-Item`` (any flags —
the interpreter is a file, so no recursion is needed to kill it), the
``find <roots> -delete`` form, and ``uv python uninstall``. Known limits:
paths assembled by ``xargs``/command substitution are invisible here, and
``shutil.rmtree`` inside executed *code* is a different sandbox.
"""
if not command or not command.strip():
return None
protected = _protected()
if not protected:
return None
for segment in _SEGMENT_SPLIT_RE.split(command):
words = _strip_prefixes(_split_words(segment))
if not words:
continue
name = os.path.basename(words[0]).lower()
if name == "uv":
target = _uv_uninstall_target(words)
if target:
return target
continue
if name == "find" and _FIND_DELETE_RE.search(segment):
for word in words[1:]:
if not word.startswith("-"):
hit = is_protected_path(word)
if hit:
return hit
continue
if name not in _DELETING_COMMANDS:
continue
for word in words[1:]:
if word.startswith("-"):
continue
hit = is_protected_path(word)
if hit:
return hit
return None