From dbbf102b8e1877924353b001a8724ef533c8f2af Mon Sep 17 00:00:00 2001 From: Dale Nguyen Date: Sun, 28 Jun 2026 00:54:22 +0530 Subject: [PATCH] fix(terminal): strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes gateway runs inside its own venv, so its process environment carries VIRTUAL_ENV (and possibly CONDA_PREFIX). The terminal tool spawned subprocesses inheriting those markers. When the agent ran `uv sync`, `uv pip install`, `poetry install`, etc. in ANY other project directory, those tools honored the inherited VIRTUAL_ENV and rebuilt/synced that project's dependencies into the Hermes venv path — wiping Hermes' own runtime deps (and, when the other project pinned a different Python, replacing the interpreter), bricking the gateway on the next restart (#23473). Strip VIRTUAL_ENV/CONDA_PREFIX in both subprocess-env construction points in tools/environments/local.py — `_sanitize_subprocess_env` and `_make_run_env` — via a shared `_ACTIVE_VENV_MARKER_VARS` constant. The Hermes venv stays reachable because its bin dir is already first on PATH, so removing the active-environment markers is safe and only prevents the cross-project clobber. Adds TestActiveVenvMarkerStripping: end-to-end (markers in os.environ don't reach the spawned subprocess) and unit coverage for both functions, plus a guard on the marker constant. Also adds the AUTHOR_MAP entry for the salvaged contributor. Closes #23473 --- scripts/release.py | 1 + tests/tools/test_local_env_blocklist.py | 48 +++++++++++++++++++++++++ tools/environments/local.py | 18 ++++++++++ 3 files changed, 67 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 2e2fd3deb6c..3ae7440e586 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "dale@dalenguyen.me": "dalenguyen", # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473) "blaryx@gmail.com": "Blaryxoff", # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396) "diamondeyesfox@gmail.com": "DiamondEyesFox", # PR #53351 salvage (rebaseline in-place compression flushes to prevent duplicate compacted rows; #9096) "piyrw9754@gmail.com": "rlaope", # PR #35075 salvage (align cron invisible-unicode set with install-time scanner; #35075) diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 2a016d49f4d..005dd2a123f 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -239,6 +239,54 @@ class TestForceEnvOptIn: assert result_env["OPENAI_BASE_URL"] == "http://intended/v1" +class TestActiveVenvMarkerStripping: + """Active-virtualenv markers must not leak into terminal subprocesses (#23473). + + The gateway runs inside its own venv, so its process environment carries + VIRTUAL_ENV (and possibly CONDA_PREFIX). If those leak into commands the + agent runs against ANOTHER Python project, ``uv``/``poetry`` treat the + inherited value as the active environment and build that project's deps + into the Hermes venv path instead of the project's own ``.venv`` — + silently clobbering the Hermes environment (and, when the other project + pins a different Python, breaking the gateway outright). The Hermes venv + stays reachable via PATH, so stripping the markers is safe. + """ + + def test_virtualenv_marker_stripped_end_to_end(self): + result_env = _run_with_env(extra_os_env={ + "VIRTUAL_ENV": "/home/user/.hermes/hermes-agent/venv", + }) + assert "VIRTUAL_ENV" not in result_env + + def test_conda_prefix_marker_stripped_end_to_end(self): + result_env = _run_with_env(extra_os_env={ + "CONDA_PREFIX": "/opt/conda/envs/hermes", + }) + assert "CONDA_PREFIX" not in result_env + + def test_make_run_env_strips_markers(self): + from tools.environments.local import _make_run_env + poison = {"VIRTUAL_ENV": "/venv", "CONDA_PREFIX": "/conda", "PATH": "/usr/bin"} + with patch.dict(os.environ, poison, clear=True): + result = _make_run_env({}) + assert "VIRTUAL_ENV" not in result + assert "CONDA_PREFIX" not in result + + def test_sanitize_subprocess_env_strips_markers(self): + from tools.environments.local import _sanitize_subprocess_env + base = {"VIRTUAL_ENV": "/venv", "CONDA_PREFIX": "/conda", "HOME": "/home/user"} + # Even an explicitly-passed extra marker is stripped. + result = _sanitize_subprocess_env(base, {"VIRTUAL_ENV": "/also/venv"}) + assert "VIRTUAL_ENV" not in result + assert "CONDA_PREFIX" not in result + assert result.get("HOME") == "/home/user" + + def test_markers_constant_contents(self): + from tools.environments.local import _ACTIVE_VENV_MARKER_VARS + assert "VIRTUAL_ENV" in _ACTIVE_VENV_MARKER_VARS + assert "CONDA_PREFIX" in _ACTIVE_VENV_MARKER_VARS + + class TestBlocklistCoverage: """Sanity checks that the blocklist covers all known providers.""" diff --git a/tools/environments/local.py b/tools/environments/local.py index 9a0558e5af1..71ba4e97f43 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -192,6 +192,18 @@ def _build_provider_env_blocklist() -> frozenset: _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() +# Active-virtualenv markers that must NOT leak into terminal subprocesses. +# The gateway runs inside its own venv, so its process environment carries +# VIRTUAL_ENV (and possibly CONDA_PREFIX). If those leak into commands the +# agent runs against OTHER Python projects, tools like ``uv``/``poetry`` treat +# the inherited value as the active environment and build/sync that other +# project's dependencies into the Hermes venv path instead of the project's own +# ``.venv`` — silently clobbering the Hermes environment (e.g. a project pinned +# to a different Python version overwrites it and breaks the gateway). The +# Hermes venv stays reachable via PATH (its bin dir is first), so stripping +# these markers is safe and only prevents the cross-project clobber (#23473). +_ACTIVE_VENV_MARKER_VARS = ("VIRTUAL_ENV", "CONDA_PREFIX") + def _inject_context_hermes_home(env: dict) -> None: """Bridge the context-local Hermes home override into subprocess env.""" @@ -232,6 +244,9 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non from hermes_constants import apply_subprocess_home_env apply_subprocess_home_env(sanitized) + for _marker in _ACTIVE_VENV_MARKER_VARS: + sanitized.pop(_marker, None) + return sanitized @@ -528,6 +543,9 @@ def _make_run_env(env: dict) -> dict: except Exception: pass + for _marker in _ACTIVE_VENV_MARKER_VARS: + run_env.pop(_marker, None) + return run_env