Files
hermes-agent/tests/agent/test_subprocess_env_guard.py
kshitijk4poor b9c2cc925f fix(tui): the compute host keeps Hermes' full environment again
The compute host runs dashboard agent turns, so it is Hermes itself, not
a third-party child: the os.environ layer is deliberate (#65895). Without
it, keys that exist only in the process env (Docker -e, systemd,
launchd) were gone from turns: HASS_TOKEN (Home Assistant tools),
MODAL_TOKEN_ID / DAYTONA_API_KEY (those terminal backends), auxiliary
keys and platform send tokens, and HOME was rewritten in containers. The
.env ones came straight back anyway, since the host reloads .env at
start. Restore the layer and allowlist the file in the spawn-env guard.
2026-09-29 02:06:58 +05:30

133 lines
5.9 KiB
Python

"""Lint guard: no new raw ``os.environ.copy()`` / ``**os.environ`` spawn-env sites.
Every child-process env in the codebase must be built through
``tools.environments.local.build_subprocess_env`` (or its sibling
``hermes_subprocess_env`` / ``_sanitize_subprocess_env``, which the factory
wraps) so profile-home propagation and secret-scrubbing have a single owner.
History: ~11 commits over 6 months each fixed one more spawn site that missed
``HERMES_HOME`` or secret-scrub propagation.
This test greps the source tree for ``os.environ.copy()`` or a ``{**os.environ, ...}``
spread appearing within ``PROXIMITY_LINES`` lines of a spawn call (``Popen`` / ``subprocess.run`` /
``create_subprocess*`` / ``PtyProcess.spawn`` / ``execvpe``) and asserts every
hit is in the explicit allowlist below. If you are adding a new spawn site:
* use ``build_subprocess_env(...)`` — with ``scrub_secrets=False,
inherit_profile_home=False`` if you need today's exact-inherit behavior; or
* consciously add the file to ``ALLOWED_RAW_SPAWN_ENV_FILES`` with a comment
explaining why the factory cannot be used.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# Directories that make up the shipped source tree.
SCAN_DIRS = ("agent", "hermes_cli", "tools", "gateway", "cron", "tui_gateway", "plugins")
SCAN_ROOT_FILES = ("cli.py", "hermes_constants.py")
# How many lines around an `os.environ.copy()` we look for a spawn call.
PROXIMITY_LINES = 20
SPAWN_RE = re.compile(
r"\bPopen\b|\bsubprocess\.run\b|\bcreate_subprocess|\bPtyProcess\.spawn\b"
r"|\bexecvpe\b|\bptyprocess\.PtyProcess\b|\bspawn\("
)
# A spread placed after the scrub restores everything it removed (#122751 review B1).
COPY_RE = re.compile(r"\bos\.environ\.copy\(\)|\*\*os\.environ\b")
# ---------------------------------------------------------------------------
# ALLOWLIST — intentionally-raw sites. Each entry is a relative posix path.
# Adding to this list is a conscious decision: document WHY inline here.
# ---------------------------------------------------------------------------
ALLOWED_RAW_SPAWN_ENV_FILES = {
# THE owner module: _sanitize_subprocess_env / hermes_subprocess_env /
# build_subprocess_env legitimately snapshot os.environ — everything else
# delegates to them.
"tools/environments/local.py",
# Bang-shell (`!cmd` in the CLI) goes through build_subprocess_env(); the
# only raw copy is the except-fallback for when the tools package itself
# cannot be imported, so the user's typed command still runs.
"hermes_cli/bang_shell.py",
# These children are Hermes itself and need its full environment: the gateway respawn
# watcher (generated script source), the launchd stderr-timestamp wrapper around the
# gateway command, and the skills sync that seeds a new profile.
"hermes_cli/gateway.py",
"hermes_cli/stderr_timestamp.py",
"hermes_cli/profiles.py",
# The compute host runs agent turns for the dashboard: Home Assistant tools, Modal/Daytona
# backends and platform sends read keys that exist only in the process env (#65895).
"tui_gateway/host_supervisor.py",
# apt/dnf/pacman run as root, through sudo (which resets the environment) or because Hermes
# already is root. A root child can read every process's environment anyway, and the scrub
# helpers would point TMPDIR into HERMES_HOME's scratch dir, leaving root-owned files there.
"tools/bot_desktop/install.py",
# The docker/ssh CLIENT process: the user's own backend binary, never code the agent runs,
# needing their real HOME for ~/.ssh and ~/.docker. Same as inheriting (``None``) plus the
# forwarded values; what crosses into the sandbox is the backend's forward/passthrough policy.
"tools/environments/remote_common.py",
# Needing provider keys is not a reason to be here: such a child uses
# hermes_subprocess_env(inherit_credentials=True), which still drops bot/relay tokens.
}
def _iter_source_files():
for d in SCAN_DIRS:
root = REPO_ROOT / d
if root.is_dir():
yield from root.rglob("*.py")
for f in SCAN_ROOT_FILES:
p = REPO_ROOT / f
if p.is_file():
yield p
def _raw_spawn_env_sites():
"""Return [(relpath, lineno)] of os.environ.copy() near a spawn call."""
sites = []
for path in _iter_source_files():
rel = path.relative_to(REPO_ROOT).as_posix()
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for i, line in enumerate(lines):
stripped = line.lstrip()
if stripped.startswith("#"):
continue # comments referencing the old pattern are fine
if not COPY_RE.search(line.split("#", 1)[0]):
continue
lo = max(0, i - PROXIMITY_LINES)
hi = min(len(lines), i + PROXIMITY_LINES + 1)
window = "\n".join(lines[lo:hi])
if SPAWN_RE.search(window):
sites.append((rel, i + 1))
return sites
def test_no_new_raw_environ_copy_spawn_sites():
sites = _raw_spawn_env_sites()
offenders = [
f"{rel}:{lineno}"
for rel, lineno in sites
if rel not in ALLOWED_RAW_SPAWN_ENV_FILES
]
assert not offenders, (
"New raw os.environ.copy() / **os.environ spawn-env site(s) found:\n "
+ "\n ".join(offenders)
+ "\nUse tools.environments.local.build_subprocess_env() instead "
"(scrub_secrets=False, inherit_profile_home=False preserves exact "
"legacy behavior), or consciously extend "
"ALLOWED_RAW_SPAWN_ENV_FILES in this test with a justification."
)
def test_allowlist_entries_still_exist():
"""Prune the allowlist when files are removed/renamed."""
for rel in ALLOWED_RAW_SPAWN_ENV_FILES:
assert (REPO_ROOT / rel).is_file(), (
f"Allowlist entry {rel} no longer exists — remove it from "
"ALLOWED_RAW_SPAWN_ENV_FILES."
)