fix(agent): protect the runtime's own interpreter from agent deletes

A session asked to clean up older Pythons removed the uv-managed base
interpreter its own venv depended on; the next boot died with 'uv
trampoline failed to spawn Python child process' and no agent tool could
repair it, because the agent itself no longer started (#58748). Prior
uninstall detection (85ce25687e) only flagged package-manager commands.

Add agent/runtime_self_protection.py and wire it into both layers:

- The approval floor (_floor_block) now blocks shell commands that
  delete the running interpreter, its own venv, the pyvenv.cfg base, or
  the uv-managed install directory — rm/rmdir/rd/del/erase/Remove-Item
  with any flags, find <root> -delete, and uv python uninstall of the
  running version (including --all). The floor runs before yolo /
  approvals.mode=off / cron approve mode, so no session setting can
  bypass it.
- The file-safety write classifier denies write/patch/move/delete to the
  same paths, so the file tools cannot overwrite the interpreter either.

Only the runtime the process itself boots from is protected; every other
venv and interpreter on the machine stays manageable.

Fixes #58748
This commit is contained in:
Hermes Agent
2026-09-25 12:36:17 -05:00
committed by brooklyn!
parent c6e0f2498e
commit 541e4dc0ba
4 changed files with 450 additions and 1 deletions

View File

@@ -255,6 +255,15 @@ def _classify_write_denial(path: str) -> Optional[str]:
return "nt_namespace"
homes, resolved = _homes_and_resolved(path)
# The runtime's own interpreter/venv is never agent-writable (an overwrite
# bricks the next start exactly like a delete, #58748) — and this must fire
# BEFORE the approval-gated allow so ~/.ssh-style gating cannot re-open it.
from agent.runtime_self_protection import is_protected_path
runtime_hit = is_protected_path(path)
if runtime_hit:
return "credential"
# Approval-gated paths are allowed at this layer so interactive tools can
# prompt; checked first so the ``.ssh/`` prefix deny doesn't swallow them.
if any(resolved in build_write_approval_paths(home) for home in homes):

View File

@@ -0,0 +1,270 @@
"""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 is_protected_path(path: str) -> Optional[str]:
"""Description of the protected runtime path ``path`` touches, else ``None``."""
resolved = _normalize_path(path)
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

View File

@@ -0,0 +1,161 @@
"""The runtime's own interpreter/venv is not agent-deletable (#58748).
A Hermes session asked to clean up "older Pythons" removed the base
interpreter its own venv pointed at; the next boot died with ``uv trampoline
failed to spawn Python child process``. These tests pin both defense layers:
* the approval floor (``_floor_block``) must hard-block shell commands that
delete the running interpreter/venv/base — even under yolo;
* the file-safety write classifier must deny writes/deletes to them.
The ``_protected_snapshot`` cache is keyed on (executable, prefix), so tests
swap ``sys`` attributes and let the cache key change with them.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest import mock
import pytest
from agent import runtime_self_protection as rsp
@pytest.fixture
def fake_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""A venv whose pyvenv.cfg points at a uv-managed base interpreter."""
venv = tmp_path / "hermes-agent" / "venv"
venv.mkdir(parents=True)
exe_dir = venv / ("Scripts" if sys.platform == "win32" else "bin")
exe_dir.mkdir()
exe = exe_dir / ("python.exe" if sys.platform == "win32" else "python")
exe.write_text("", encoding="utf-8")
uv_base = tmp_path / "uv" / "python" / "cpython-3.11.9-windows-x86_64-none"
uv_base.mkdir(parents=True)
base_exe_dir = uv_base / ("Scripts" if sys.platform == "win32" else "bin")
base_exe_dir.mkdir()
(base_exe_dir / ("python.exe" if sys.platform == "win32" else "python")).write_text("", encoding="utf-8")
home_dir = str(base_exe_dir)
(venv / "pyvenv.cfg").write_text(
f"home = {home_dir}\nversion = 3.11.9\ninclude-system-site-packages = false\n",
encoding="utf-8",
)
monkeypatch.setattr(sys, "executable", str(exe))
monkeypatch.setattr(sys, "prefix", str(venv))
rsp._protected_snapshot.cache_clear()
yield {"exe": str(exe), "venv": str(venv), "uv_base": str(uv_base), "base_dir": home_dir}
rsp._protected_snapshot.cache_clear()
def test_pyvenv_home_is_parsed(fake_runtime):
assert rsp._pyvenv_home(fake_runtime["venv"]) == fake_runtime["base_dir"]
def test_rm_of_running_interpreter_is_detected(fake_runtime):
assert rsp.command_deletes_runtime(f'rm "{fake_runtime["exe"]}"') is not None
def test_rm_rf_of_own_venv_is_detected(fake_runtime):
assert rsp.command_deletes_runtime(f"rm -rf {fake_runtime['venv']}") is not None
def test_rm_of_base_interpreter_is_detected(fake_runtime):
base_exe = os.path.join(fake_runtime["base_dir"], os.listdir(fake_runtime["base_dir"])[0])
assert rsp.command_deletes_runtime(f"sudo rm -f '{base_exe}'") is not None
def test_rm_of_whole_uv_install_dir_is_detected(fake_runtime):
assert rsp.command_deletes_runtime(f"rm -rf {fake_runtime['uv_base']}") is not None
def test_uv_python_uninstall_of_running_version_is_detected(fake_runtime):
assert rsp.command_deletes_runtime("uv python uninstall 3.11") is not None
def test_uv_python_uninstall_all_is_detected(fake_runtime):
assert rsp.command_deletes_runtime("uv python uninstall --all") is not None
def test_uv_python_uninstall_of_other_version_is_allowed(fake_runtime):
assert rsp.command_deletes_runtime("uv python uninstall 3.9") is None
def test_find_delete_over_own_venv_is_detected(fake_runtime):
# Both are deletes under the protected root: an unfiltered -delete removes
# the interpreter itself, and a filtered one still deletes protected files.
assert rsp.command_deletes_runtime(f"find '{fake_runtime['venv']}' -delete") is not None
assert rsp.command_deletes_runtime(f"find '{fake_runtime['venv']}' -name __pycache__ -delete") is not None
assert rsp.command_deletes_runtime(f"find /tmp -name __pycache__ -delete") is None
def test_rm_of_unrelated_venv_is_allowed(fake_runtime, tmp_path):
other = tmp_path / "project" / ".venv"
other.mkdir(parents=True)
assert rsp.command_deletes_runtime(f"rm -rf {other}") is None
def test_windows_del_and_powershell_spellings(fake_runtime, monkeypatch):
with mock.patch.object(os, "name", "nt"):
exe = fake_runtime["exe"]
assert rsp.command_deletes_runtime(f'del "{exe}"') is not None
assert rsp.command_deletes_runtime(f'Remove-Item -Recurse -Force "{exe}"') is not None
assert rsp.command_deletes_runtime(f"rd /s /q {fake_runtime['venv']}") is not None
def test_mkdir_over_protected_path_not_flagged_as_delete(fake_runtime):
# mkdir touches the venv dir but deletes nothing — command layer says fine
# (the file-safety layer still denies the write).
assert rsp.command_deletes_runtime(f"mkdir -p {fake_runtime['venv']}") is None
def test_is_protected_path_covers_venv_children_and_base(fake_runtime):
site = os.path.join(fake_runtime["venv"], "lib", "site-packages", "x.py")
assert rsp.is_protected_path(site) is not None
assert rsp.is_protected_path(fake_runtime["base_dir"]) is not None
assert rsp.is_protected_path("/tmp/scratch.txt") is None
def test_file_safety_denies_write_to_running_interpreter(fake_runtime):
from agent.file_safety import _classify_write_denial
assert _classify_write_denial(fake_runtime["exe"]) == "credential"
assert _classify_write_denial(os.path.join(fake_runtime["venv"], "pyvenv.cfg")) == "credential"
def test_file_safety_allows_unrelated_paths(fake_runtime, tmp_path):
from agent.file_safety import _classify_write_denial
scratch = tmp_path / "scratch.txt"
assert _classify_write_denial(str(scratch)) is None
def test_approval_floor_blocks_runtime_delete_even_under_yolo(fake_runtime, monkeypatch):
from tools import approval
monkeypatch.setattr(approval, "_yolo_active", lambda: True)
result = approval._floor_block(f'rm -rf "{fake_runtime["venv"]}"')
assert result is not None
assert result.get("approved") is False
def test_check_all_guards_blocks_runtime_delete(fake_runtime, monkeypatch):
from tools import approval
# Outside CLI/gateway/ask contexts with yolo on, the floor is the only
# thing standing; it must still block.
monkeypatch.setattr(approval, "_yolo_active", lambda: True)
result = approval.check_all_command_guards(f'rm "{fake_runtime["exe"]}"', "local")
assert result.get("approved") is False
def test_normal_command_unaffected(fake_runtime, monkeypatch):
from tools import approval
monkeypatch.setattr(approval, "_yolo_active", lambda: True)
assert approval.check_all_command_guards("ls /tmp", "local").get("approved") is True

View File

@@ -1053,11 +1053,20 @@ def _floor_block(command: str, *, sudo_guard: bool = False) -> dict | None:
"""Unconditional floors, BEFORE yolo / mode=off / cron approve-mode so no
session-level setting can bypass them: hardline catastrophic commands,
password-piping to ``sudo -S`` with no SUDO_PASSWORD configured (full guard
only), and the user's own approvals.deny rules ("never, even under yolo")."""
only), the user's own approvals.deny rules ("never, even under yolo"), and
deletion of the Python interpreter/venv this very runtime boots from (a
delete the agent cannot walk back — the next start fails before any tool
can run, #58748)."""
from agent.runtime_self_protection import command_deletes_runtime
is_hardline, hardline_desc = detect_hardline_command(command)
if is_hardline:
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
return _hardline_block_result(hardline_desc, command)
runtime_target = command_deletes_runtime(command)
if runtime_target:
logger.warning("Runtime self-delete block: %s (command: %s)", runtime_target, command[:200])
return _hardline_block_result(f"recursive/any delete of {runtime_target}", command)
if sudo_guard:
is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)
if is_sudo_guess: