Files
hermes-agent/tests/home_io_guard.py
Hermes Agent 02f57212a4 test: check guarded paths with strings, not pathlib
HomeIOGuard.check built two or three Path objects per filesystem call and
ran is_relative_to() over the interpreter prefixes, the guarded roots and the
resolved target for every guarded open/stat/scandir — ~0.6ms per call. The
autouse fixture wraps every os call in every test, so a path-heavy e2e file
like the cron virtual-clock soak pays it millions of times: one scenario
measured 2.36M guard checks, i.e. ~20 minutes of pure guard CPU across its
threads, which under the e2e job's 3-way parallelism pushed the file past the
900s per-file cap with only the first scenarios complete (the CI hang of jobs
108211558961, 108211601999, 108150838362, 108233411588).

Do the whole check on case-folded strings instead: precomputed prefix
strings, os.path.abspath/realpath (C, no object churn) and explicit
separator-boundary prefix compares that reproduce Path.is_relative_to
semantics, including the filesystem root. os.path.normcase preserves the
case-insensitive compare Path.__eq__ gave on Windows. Decision order and
every exemption are unchanged; measured 611us -> 15us per guarded stat
(40x), and the full soak file drops from ~600s to ~155s in a 3-lane
container run that previously hit the 900s cap in every lane.
2026-09-25 19:28:59 -05:00

184 lines
8.5 KiB
Python

"""Guard Python filesystem calls in tests, not arbitrary native/subprocess I/O."""
from __future__ import annotations
import builtins
from functools import lru_cache, wraps
import io
import os
from pathlib import Path
import shutil
import sqlite3
import sys
import threading
_INTERPRETER_PREFIXES = tuple({
Path(p).resolve() for p in (sys.prefix, sys.base_prefix, sys.exec_prefix, sys.base_exec_prefix)
} | {
# A PM-activated developer shell runs sys.prefix's python against a dependency generation
# whose site-packages sits under the (real) Hermes home; third-party imports from it are the
# interpreter's installation, not Hermes state.
Path(p).resolve() for p in sys.path if p and Path(p).name in ("site-packages", "dist-packages")
} | {
# The default install checks the repo out INSIDE the home (install.sh:
# INSTALL_DIR=$HERMES_HOME/hermes-agent). Reading test data, sources for tracebacks, or the
# checkout's own .venv is not Hermes state; without this every run from a default install
# trips on its first traceback.
Path(__file__).resolve().parent.parent,
})
# The same prefixes as plain strings for the check() fast path. PurePath comparison folds case on
# Windows; ``os.path.normcase`` (identity on POSIX) reproduces that for string compares. Prefixes
# resolve once at import, as before: they are fixed for the process lifetime.
_normcase = os.path.normcase
_INTERPRETER_PREFIX_STRS = tuple(_normcase(os.fspath(p)) for p in _INTERPRETER_PREFIXES)
def _within(path: str, prefix: str) -> bool:
"""``Path(path).is_relative_to(prefix)`` for two normalized, case-folded absolute strings."""
if path == prefix:
return True
return path.startswith(prefix) if prefix == os.sep else path.startswith(prefix + os.sep)
def _contains(path: str, prefix: str) -> bool:
"""``Path(prefix).is_relative_to(path)``: *path* is *prefix* or one of its ancestors."""
if path == prefix:
return True
return prefix.startswith(path) if path == os.sep else prefix.startswith(path + os.sep)
class HomeIOGuard:
def __init__(self, roots):
self.roots = roots
self.checking = threading.local()
self.directories: dict[int, Path] = {}
def check(self, value, *, dir_fd=None, metadata=False):
if value is None or isinstance(value, int) or getattr(self.checking, "active", False):
return
self.checking.active = True
try:
candidate = os.fsdecode(value)
if candidate.startswith("~"):
# A test may have patched Path.expanduser to fail; the guard must not
# turn that into its own crash — the unexpanded path is checked instead.
try:
candidate = os.fspath(Path(candidate).expanduser())
except Exception:
pass
if dir_fd is not None and not os.path.isabs(candidate):
parent = self.directories.get(dir_fd)
if parent is None:
raise AssertionError("TEST BUG: untracked dir_fd in guarded filesystem I/O")
candidate = os.path.join(os.fspath(parent), candidate)
absolute = _normcase(os.path.abspath(candidate))
# /proc/<pid>/fd/N is descriptor inspection (deleted-WAL holder scans stat the magic
# link to compare inode identity); resolving it names whatever file that fd holds,
# which is not I/O against the home.
if metadata and (absolute == "/proc" or absolute.startswith("/proc" + os.sep)):
return
roots = tuple(_normcase(os.fspath(r)) for r in self.roots())
# Resolving the root itself (get_default_hermes_root's relative_to
# probe) reads no state; only its contents are guarded.
if metadata and absolute in roots:
return
# ``shutil.which`` stats/accesses ``<PATH entry>/<name>``. A developer shell puts
# PM's tool store (~/.hermes/tools/...) on PATH; probing an executable there is
# command lookup, not reading Hermes state. CI has no such entries.
if metadata:
path = os.environ.get("PATH", "")
cwd = os.getcwd() if self._relative_path_entries(path) else None
if os.path.dirname(absolute) in self._path_entries(path, cwd):
return
# The interpreter's own installation (a PM-managed python under ~/.hermes/tools):
# stdlib source reads (linecache, traceback) are not Hermes state either, nor is
# realpath() walking up through its ancestors.
for prefix in _INTERPRETER_PREFIX_STRS:
if _within(absolute, prefix) or (metadata and _contains(absolute, prefix)):
return
# Check the lexical path first: resolving must not probe a protected
# tree merely to decide that the original path was forbidden.
for root in roots:
if _within(absolute, root):
self.refuse(value)
resolved = _normcase(os.path.realpath(absolute))
if metadata and resolved in roots:
return
# A fixture symlink to the running interpreter resolves into its installation.
for prefix in _INTERPRETER_PREFIX_STRS:
if _within(resolved, prefix):
return
for root in roots:
if _within(resolved, root):
self.refuse(value)
finally:
self.checking.active = False
@staticmethod
def refuse(value):
raise AssertionError(
f"TEST BUG: file I/O against the REAL hermes home: {value}\n"
"Use the isolated HERMES_HOME or a temporary fixture instead."
)
@staticmethod
@lru_cache(maxsize=8)
def _relative_path_entries(path: str) -> bool:
return any(entry and not os.path.isabs(entry) for entry in path.split(os.pathsep))
@staticmethod
@lru_cache(maxsize=8)
def _path_entries(path: str, cwd: str | None):
# Relative PATH entries change meaning after chdir; absolute ones need no cwd.
return frozenset(
_normcase(os.path.normpath(os.path.join(cwd or "", entry)))
for entry in path.split(os.pathsep) if entry
)
def install(self, monkeypatch):
def wrap(module, name, parameters, *, metadata=False):
original = getattr(module, name)
@wraps(original)
def guarded(*args, **kwargs):
for index, (parameter, descriptor) in enumerate(parameters):
value = args[index] if index < len(args) else kwargs.get(parameter)
self.check(value, dir_fd=kwargs.get(descriptor) if descriptor else None, metadata=metadata)
return original(*args, **kwargs)
monkeypatch.setattr(module, name, guarded)
for module in (builtins, io):
wrap(module, "open", (("file", None),))
for name in ("mkdir", "unlink", "remove", "rmdir", "chmod", "utime"):
wrap(os, name, (("path", "dir_fd"),))
for name in ("stat", "lstat", "readlink", "access"):
wrap(os, name, (("path", "dir_fd"),), metadata=True)
for name in ("makedirs", "listdir", "scandir"):
wrap(os, name, (("name" if name == "makedirs" else "path", None),))
for name in ("rename", "replace"):
wrap(os, name, (("src", "src_dir_fd"), ("dst", "dst_dir_fd")))
wrap(shutil, "rmtree", (("path", "dir_fd"),))
wrap(sqlite3, "connect", (("database", None),))
original_open, original_close = os.open, os.close
@wraps(original_open)
def guarded_open(path, flags, *args, **kwargs):
self.check(path, dir_fd=kwargs.get("dir_fd"))
fd = original_open(path, flags, *args, **kwargs)
candidate = Path(os.fsdecode(path))
if kwargs.get("dir_fd") is not None and not candidate.is_absolute():
candidate = self.directories[kwargs["dir_fd"]] / candidate
self.directories[fd] = candidate.absolute()
return fd
@wraps(original_close)
def guarded_close(fd):
# Forget the old owner before close releases the number for reuse
# by another thread's open; afterwards we could erase its mapping.
self.directories.pop(fd, None)
return original_close(fd)
monkeypatch.setattr(os, "open", guarded_open)
monkeypatch.setattr(os, "close", guarded_close)