Merge branch 'simp/r3-32-E' into simp/r3-32

This commit is contained in:
Teknium
2026-09-02 23:46:49 -07:00
7 changed files with 559 additions and 1102 deletions

View File

@@ -719,7 +719,8 @@ class TestLocalEnvironmentWindowsTempDir:
source = (root / "tools" / "environments" / "local.py").read_text(encoding="utf-8")
assert "if _IS_WINDOWS:" in source
assert "get_hermes_home" in source
assert 'cache_dir = get_hermes_home() / "cache" / "terminal"' in source
assert 'get_hermes_home() / "cache" / "terminal"' in source
assert "_default_terminal_temp_dir()" in source
class TestLocalEnvironmentPathInjectionGated:

View File

@@ -1,24 +1,15 @@
"""Environment variable passthrough registry.
Skills that declare ``required_environment_variables`` need those vars in
sandboxed execution environments (execute_code, terminal), which strip secrets
from the child process environment by default. This module is the
session-scoped allowlist, fed by two sources: skill declarations (registered
automatically by ``skill_view``) and ``terminal.env_passthrough`` in
config.yaml.
``code_execution_tool.py`` and ``tools/environments/local.py`` consult
:func:`is_env_passthrough` before stripping a variable. When profile
multiplexing is active, forwarded values are resolved through the current
profile's secret scope rather than the process environment.
"""
"""Environment variable passthrough registry: the session-scoped allowlist of vars a
skill's ``required_environment_variables`` (registered by ``skill_view``) or
``terminal.env_passthrough`` in config.yaml may forward into sandboxed children
(execute_code, terminal), which strip secrets by default. Under profile multiplexing,
forwarded values resolve through the profile's secret scope, not the process env."""
from __future__ import annotations
import logging
from contextvars import ContextVar
from typing import Iterable
from hermes_cli.config import cfg_get
from hermes_cli.config import cfg_get, read_raw_config
logger = logging.getLogger(__name__)
@@ -43,110 +34,78 @@ _config_passthrough: frozenset[str] | None = None
def _is_hermes_provider_credential(name: str) -> bool:
"""True if ``name`` is a Hermes-managed provider credential per
``_HERMES_PROVIDER_ENV_BLOCKLIST`` (or a dynamic Hermes-internal secret).
Skill-declared ``required_environment_variables`` must not override this
list — that was the GHSA-rhgp-j443-p4rf bypass, where a malicious skill
registered ``OPENAI_API_KEY`` as passthrough and received it in the
``execute_code`` child, defeating the sandbox's scrubbing guarantee.
Non-Hermes API keys (TENOR_API_KEY, NOTION_TOKEN, …) are not in the
blocklist and remain registerable.
Fail closed: if the authoritative blocklist cannot be imported (partial
install, import-time error), treat the name as protected and refuse
passthrough rather than fall open.
"""
``_HERMES_PROVIDER_ENV_BLOCKLIST`` or a dynamic Hermes-internal secret
(AUXILIARY_*_API_KEY / _BASE_URL, GATEWAY_RELAY_*). Skill-declared
``required_environment_variables`` must not override this — that was the
GHSA-rhgp-j443-p4rf bypass (a skill registered ``OPENAI_API_KEY`` and received it
in the ``execute_code`` child); non-Hermes keys (TENOR_API_KEY, …) stay
registerable. Fails closed when the blocklist cannot be imported."""
try:
from tools.environments.local import (
_HERMES_PROVIDER_ENV_BLOCKLIST,
_is_hermes_internal_secret,
)
_HERMES_PROVIDER_ENV_BLOCKLIST, _is_hermes_internal_secret)
except Exception as e:
logger.warning(
"env passthrough: provider credential blocklist import failed; "
"failing closed and refusing passthrough registration for %r: %s",
name,
e,
)
"failing closed and refusing passthrough registration for %r: %s", name, e)
return True
# Dynamically-generated Hermes-internal secrets (AUXILIARY_*_API_KEY /
# _BASE_URL, GATEWAY_RELAY_*) are injected per task/relay at gateway
# startup, so the static blocklist can't enumerate them.
if _is_hermes_internal_secret(name):
return True
return name in _HERMES_PROVIDER_ENV_BLOCKLIST
return _is_hermes_internal_secret(name) or name in _HERMES_PROVIDER_ENV_BLOCKLIST
def register_env_passthrough(var_names: Iterable[str]) -> None:
"""Register env var names as allowed in sandboxed environments (typically
from a skill's ``required_environment_variables``).
Hermes-managed provider credentials are rejected to preserve the
``execute_code`` sandbox's credential-scrubbing guarantee
(GHSA-rhgp-j443-p4rf); a skill needing a Hermes-managed provider should
use the main-process tools (web_search, web_extract, …) where the
credential stays in the main process. Third-party keys pass normally.
"""
for name in var_names:
name = name.strip()
if not name:
continue
if _is_hermes_provider_credential(name):
logger.warning(
"env passthrough: refusing to register Hermes provider "
"credential %r (blocked by _HERMES_PROVIDER_ENV_BLOCKLIST). "
"Skills must not override the execute_code sandbox's "
"credential scrubbing; see GHSA-rhgp-j443-p4rf.",
name,
)
continue
"""Register env var names as allowed in sandboxed environments (typically a
skill's ``required_environment_variables``). Hermes-managed provider credentials
are rejected (GHSA-rhgp-j443-p4rf) — such skills should use the main-process tools
(web_search, web_extract, …); third-party keys pass normally."""
for name in _accepted((n.strip() for n in var_names), (
"env passthrough: refusing to register Hermes provider "
"credential %r (blocked by _HERMES_PROVIDER_ENV_BLOCKLIST). "
"Skills must not override the execute_code sandbox's "
"credential scrubbing; see GHSA-rhgp-j443-p4rf."
)):
_get_allowed().add(name)
logger.debug("env passthrough: registered %s", name)
def _accepted(names, refusal_msg: str):
"""Yield non-empty *names* that are not Hermes provider credentials; refused
names are logged with *refusal_msg* (``%r`` = name)."""
for name in names:
if not name:
continue
if _is_hermes_provider_credential(name):
logger.warning(refusal_msg, name)
continue
yield name
def _load_config_passthrough() -> frozenset[str]:
"""Load ``tools.env_passthrough`` from config.yaml (cached)."""
"""Load ``tools.env_passthrough`` from config.yaml (cached). Same credential
filter as register_env_passthrough: operator config must not tunnel provider
credentials into sandbox children either (GHSA-rhgp-j443-p4rf)."""
global _config_passthrough
if _config_passthrough is not None:
return _config_passthrough
result: set[str] = set()
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
passthrough = cfg_get(cfg, "terminal", "env_passthrough")
if isinstance(passthrough, list):
for item in passthrough:
if not isinstance(item, str) or not item.strip():
continue
name = item.strip()
# Same filter as register_env_passthrough: provider credentials
# must not reach sandbox children whether the request came from
# a skill or from config.yaml (GHSA-rhgp-j443-p4rf).
if _is_hermes_provider_credential(name):
logger.warning(
"env passthrough: refusing to register Hermes "
"provider credential %r from config.yaml (blocked "
"by _HERMES_PROVIDER_ENV_BLOCKLIST). Operator "
"configuration must not override the execute_code "
"sandbox's credential scrubbing; see "
"GHSA-rhgp-j443-p4rf.",
name,
)
continue
result.add(name)
passthrough = cfg_get(read_raw_config(), "terminal", "env_passthrough")
items = passthrough if isinstance(passthrough, list) else ()
result.update(_accepted((i.strip() for i in items if isinstance(i, str)), (
"env passthrough: refusing to register Hermes "
"provider credential %r from config.yaml (blocked "
"by _HERMES_PROVIDER_ENV_BLOCKLIST). Operator "
"configuration must not override the execute_code "
"sandbox's credential scrubbing; see "
"GHSA-rhgp-j443-p4rf."
)))
except Exception as e:
logger.debug("Could not read tools.env_passthrough from config: %s", e)
_config_passthrough = frozenset(result)
return _config_passthrough
def is_env_passthrough(var_name: str) -> bool:
"""True if *var_name* was registered by a skill or listed in config."""
if var_name in _get_allowed():
return True
return var_name in _load_config_passthrough()
return var_name in _get_allowed() or var_name in _load_config_passthrough()
def get_all_passthrough() -> frozenset[str]:
@@ -154,41 +113,22 @@ def get_all_passthrough() -> frozenset[str]:
return frozenset(_get_allowed()) | _load_config_passthrough()
def resolve_passthrough_value(
name: str,
fallback: str | None = None,
) -> str | None:
"""Resolve an allowlisted variable without crossing profile boundaries.
``fallback`` is the value the caller would have forwarded before profile
secret scopes existed (typically a snapshot of ``os.environ`` or the
current profile's ``.env``). An active multiplex scope is authoritative:
a missing key returns ``None`` and never falls back to the process-global
environment; an unscoped read while multiplexing is active raises the
fail-closed ``UnscopedSecretError`` from :mod:`agent.secret_scope`.
Outside multiplexing, an installed scope keeps the overlay semantics and
an unscoped caller keeps its already-resolved fallback.
"""
def resolve_passthrough_value(name: str, fallback: str | None = None) -> str | None:
"""Resolve an allowlisted variable without crossing profile boundaries. ``fallback``
is what the caller would have forwarded before secret scopes existed (a snapshot of
``os.environ`` / the profile ``.env``). An active multiplex scope is authoritative:
a missing key returns ``None``, never the process-global env, and an unscoped read
raises the fail-closed ``UnscopedSecretError``. Outside multiplexing an installed
scope keeps overlay semantics and an unscoped caller keeps its fallback."""
from agent.secret_scope import (
_is_global_env,
current_secret_scope,
get_secret,
is_multiplex_active,
)
# Global terminal/runtime settings are not profile secrets. ``fallback``
# is already the caller's effective value (including an explicit per-call
# override), so preserve it rather than replacing it with the process-wide
# value while a multiplex scope is active.
_is_global_env, current_secret_scope, get_secret, is_multiplex_active)
# Global terminal/runtime settings are not profile secrets; ``fallback`` is
# already the caller's effective value (incl. an explicit per-call override).
if _is_global_env(name) and fallback is not None:
return fallback
scope = current_secret_scope()
multiplex_active = is_multiplex_active()
if scope is None:
if multiplex_active:
return get_secret(name)
return fallback
if current_secret_scope() is None:
return get_secret(name) if multiplex_active else fallback
return get_secret(name, None if multiplex_active else fallback)

View File

@@ -1,17 +1,8 @@
"""Local-environment toolchain probe for the system prompt.
When the terminal backend is local, surface one deterministic line about
Python tooling state (python3/python versions, missing pip module, pip bound
to a different Python than ``python3``, PEP 668 externally-managed) so models
don't discover it by hitting walls. The probe is cheap (~50ms), cached for
the process lifetime, and emits nothing when the environment is clean.
Remote terminal backends (docker, modal, ssh, …) are skipped: the host's
Python state is irrelevant when tools run inside a sandbox, which has its own
probe (``_probe_remote_backend`` in ``agent/prompt_builder.py``).
Toggle via ``agent.environment_probe`` in config.yaml (default True).
"""
"""Local-environment toolchain probe for the system prompt: when the terminal backend
is local, one deterministic line about Python tooling (python3/python versions, missing
pip, pip bound to another Python, PEP 668) so models don't discover it by hitting
walls. Cached per process; "" when clean. Remote backends are skipped (the sandbox has
its own probe in agent/prompt_builder). Toggle: ``agent.environment_probe`` in config.yaml."""
from __future__ import annotations
@@ -26,26 +17,17 @@ from hermes_cli._subprocess_compat import windows_hide_flags
logger = logging.getLogger(__name__)
# Concurrency model: the probe runs in exactly ONE background worker thread;
# ``_PROBE_DONE`` signals completion. Callers never execute the probe
# themselves and block at most ``_PROBE_WAIT_TIMEOUT`` seconds on the event
# before failing open with "" — a stuck probe (e.g. a Windows pipe wedged open
# by an orphaned pip descendant) can degrade only the probe line, never
# system-prompt construction.
# Concurrency model: exactly ONE background worker runs the probe; ``_PROBE_DONE``
# signals completion. Callers block at most ``_PROBE_WAIT_TIMEOUT`` s then fail open
# with "" — a stuck probe (e.g. a Windows pipe wedged by an orphaned pip descendant)
# can degrade only the probe line, never system-prompt construction.
_CACHE_LOCK = threading.Lock()
_CACHED_LINE: Optional[str] = None # None = not probed yet; "" = probed, nothing to say.
_PROBE_DONE = threading.Event()
_PROBE_THREAD: Optional[threading.Thread] = None
# Generation counter — bumped on every reset so a stale worker (started
# before a test reset) can't publish its result into the fresh generation.
_PROBE_GEN = 0
# Upper bound a prompt build will wait for the probe. Generous vs the ~0.5s
# healthy runtime, but finite: prompt construction must always proceed.
_PROBE_WAIT_TIMEOUT = 10.0
# Once one caller has burned the full wait, later callers only peek at the
# event. If the stuck worker ever finishes, the line resumes appearing.
_WAIT_ALREADY_TIMED_OUT = False
_PROBE_GEN = 0 # bumped on reset so a stale worker can't publish into the fresh generation
_PROBE_WAIT_TIMEOUT = 10.0 # healthy runtime ~0.5s
_WAIT_ALREADY_TIMED_OUT = False # after one full wait, later callers only peek
# Keep in sync with agent/prompt_builder.py:_REMOTE_TERMINAL_BACKENDS.
# Duplicated rather than imported to avoid a circular import.
@@ -68,90 +50,63 @@ def _plugin_backend_is_remote(backend: str) -> bool:
def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
"""Run a short subprocess. Returns (returncode, stdout, stderr).
Failures (binary missing, timeout, OSError) return (-1, "", "<reason>").
Output is captured through temp files rather than pipes so ``timeout``
bounds the *whole* call, even on native Windows: a console-script launcher
(e.g. ``pip.exe``) can spawn a descendant that inherits the captured
handles and outlives its parent. With OS pipes, ``communicate()``'s reader
threads block until that grandchild closes the write end — which the
timeout does not cover, since killing the direct child leaves the
grandchild holding the pipe (a warm probe could hang ~28 min holding
``_CACHE_LOCK``). Temp files have no reader threads, so ``wait()`` only
waits on the direct child and the probe genuinely fails open on timeout.
"""
"""Run a short subprocess -> (returncode, stdout, stderr); failures (binary
missing, timeout, OSError) return (-1, "", "<reason>"). Output goes through temp
files, not pipes, so ``timeout`` bounds the *whole* call even on native Windows: a
console-script launcher (``pip.exe``) can spawn a descendant that inherits the
captured handles and outlives its parent; with OS pipes ``communicate()``'s reader
threads block until that grandchild closes the write end (a warm probe could hang
~28 min holding ``_CACHE_LOCK``). Temp files make ``wait()`` cover only the child."""
try:
with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
try:
result = subprocess.run(
cmd,
stdout=out_f,
stderr=err_f,
timeout=timeout,
check=False,
stdin=subprocess.DEVNULL,
# CREATE_NO_WINDOW (0 on POSIX): windowless hosts (pythonw
# gateway / kanban workers) would otherwise flash a console
# window per probe subprocess.
creationflags=windows_hide_flags(),
)
cmd, stdout=out_f, stderr=err_f, timeout=timeout, check=False,
# CREATE_NO_WINDOW (0 on POSIX): pythonw hosts would flash a console
stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
except subprocess.TimeoutExpired:
return -1, "", "timeout"
out_f.seek(0)
err_f.seek(0)
out = out_f.read().decode("utf-8", "replace").strip()
err = err_f.read().decode("utf-8", "replace").strip()
return result.returncode, out, err
return (result.returncode, out_f.read().decode("utf-8", "replace").strip(),
err_f.read().decode("utf-8", "replace").strip())
except FileNotFoundError:
return -1, "", "not found"
except OSError as exc:
return -1, "", f"oserror: {exc}"
def _python_version_of(binary: str) -> Optional[str]:
"""Return a short version string like ``3.12.4`` for ``binary``, or None."""
def _py_out(binary: str, *args: str) -> Optional[str]:
"""stdout of ``<binary> *args`` when the binary is on PATH and exits 0, else None."""
if not shutil.which(binary):
return None
rc, out, err = _run([binary, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"])
return out if rc == 0 and out else None
rc, out, _err = _run([binary, *args])
return out if rc == 0 else None
def _python_version_of(binary: str) -> Optional[str]:
"""Return a short version string like ``3.12.4`` for ``binary``, or None."""
code = "import sys; print('.'.join(map(str, sys.version_info[:3])))"
return _py_out(binary, "-c", code) or None
def _has_pip_module(binary: str) -> bool:
"""True if ``<binary> -m pip --version`` succeeds."""
if not shutil.which(binary):
return False
rc, _out, _err = _run([binary, "-m", "pip", "--version"])
return rc == 0
return _py_out(binary, "-m", "pip", "--version") is not None
def _detect_pep668(binary: str) -> bool:
"""True when ``<binary>`` is PEP-668 externally-managed (``EXTERNALLY-MANAGED``
marker next to the stdlib, as Debian/Ubuntu ship)."""
if not shutil.which(binary):
return False
code = (
"import sys, os;"
"stdlib = os.path.dirname(os.__file__);"
"marker = os.path.join(stdlib, 'EXTERNALLY-MANAGED');"
"print('yes' if os.path.exists(marker) else 'no')"
)
rc, out, _err = _run([binary, "-c", code])
return rc == 0 and out.strip() == "yes"
code = ("import os; print('yes' if os.path.exists(os.path.join("
"os.path.dirname(os.__file__), 'EXTERNALLY-MANAGED')) else 'no')")
return (_py_out(binary, "-c", code) or "").strip() == "yes"
def _pip_python_version() -> Optional[str]:
"""If ``pip`` is on PATH, return the Python version it's bound to.
Parses the trailing ``(python X.Y)`` of ``pip --version`` output, e.g.
``pip 24.0 from /usr/lib/python3/dist-packages/pip (python 3.12)`` → ``"3.12"``.
"""
if not shutil.which("pip"):
return None
rc, out, _err = _run(["pip", "--version"])
if rc != 0 or not out:
return None
"""If ``pip`` is on PATH, the Python version it's bound to — the trailing
``(python X.Y)`` of ``pip --version`` (e.g. ``"3.12"``), else None."""
out = _py_out("pip", "--version") or ""
if "(python " in out and out.endswith(")"):
return out.rsplit("(python ", 1)[1][:-1].strip()
return None
@@ -169,104 +124,74 @@ def _resolve_terminal_backend() -> str:
def _build_probe_line() -> str:
"""Build the one-liner. Returns "" when nothing notable is detected —
the goal is to save the model from an avoidable wall, not narrate a
healthy environment."""
"""Build the one-liner; "" when nothing notable is detected — the goal is to
save the model from an avoidable wall, not narrate a healthy environment."""
py3_ver = _python_version_of("python3")
py_ver = _python_version_of("python") # for systems with a `python` alias
py3_has_pip = _has_pip_module("python3") if py3_ver else False
pip_bound_to = _pip_python_version()
py3_pep668 = _detect_pep668("python3") if py3_ver else False
# Bare which() is correct here, unlike Hermes's own uv call sites: this
# reports the environment *the model will see* in the terminal tool, whose
# PATH (via local.py) includes the Hermes-managed $HERMES_HOME/bin.
# Claiming uv the model cannot invoke would be worse than claiming none.
# Bare which() is correct here (unlike Hermes's own uv call sites): this reports
# the environment *the model will see* in the terminal tool, whose PATH includes
# the Hermes-managed $HERMES_HOME/bin via local.py.
has_uv = shutil.which("uv") is not None
mismatch = bool(pip_bound_to and py3_ver and not py3_ver.startswith(pip_bound_to))
silent_conditions = (
py3_ver is not None
and py3_has_pip
and not mismatch
and (not py3_pep668 or has_uv)
)
if silent_conditions:
if py3_ver is not None and py3_has_pip and not mismatch and (not py3_pep668 or has_uv):
return ""
# Compact factual summary; ONE line so it doesn't dominate the prompt.
bits: list[str] = []
if py3_ver:
py3_bit = f"python3={py3_ver}"
if not py3_has_pip:
py3_bit += " (no pip module)"
bits.append(py3_bit)
bits.append(f"python3={py3_ver}" + ("" if py3_has_pip else " (no pip module)"))
else:
bits.append("python3=missing")
if py_ver and py_ver != py3_ver:
bits.append(f"python={py_ver}")
elif not py_ver and py3_ver:
# Common on Debian/Ubuntu — stop the model typing `python`.
bits.append("python=missing (use python3)")
if pip_bound_to:
if mismatch:
bits.append(f"pip→python{pip_bound_to} (mismatch)")
elif not py3_has_pip:
# pip script works but `python3 -m pip` doesn't.
bits.append(f"pip→python{pip_bound_to}")
bits.append(f"pip→python{pip_bound_to}") # pip script works, `-m pip` doesn't
elif not py3_has_pip:
# (when `pip` is off PATH but `python3 -m pip` works, say nothing)
bits.append("pip=missing")
if py3_pep668:
bits.append("PEP 668=yes (use venv or uv)")
if has_uv:
bits.append("uv=installed")
return "Python toolchain: " + ", ".join(bits) + "."
def get_environment_probe_line(*, force_refresh: bool = False) -> str:
"""Return the cached probe line (building it on first call).
Returns "" when the environment is clean — the system prompt assembler
should drop the section rather than emit an empty heading. The probe runs
in a single background worker; this waits at most ``_PROBE_WAIT_TIMEOUT``
seconds on its completion event and then fails open with "", so a wedged
probe subprocess can never block system-prompt construction.
``force_refresh`` is for tests; real callers should never need it.
"""
"""Return the cached probe line (building it on first call); "" when the
environment is clean, so the prompt assembler drops the section. Waits at most
``_PROBE_WAIT_TIMEOUT`` on the single worker, then fails open with "".
``force_refresh`` is for tests."""
global _WAIT_ALREADY_TIMED_OUT
if force_refresh:
_reset_cache_for_tests()
# Resolve the backend HERE, in the caller's context: under gateway
# multiplexing the routed profile's backend lives in the per-turn terminal
# scope, which the bare probe worker thread does not inherit. A remote
# backend answers "" without consulting the cache — the cached line
# describes the HOST toolchain, not where that profile's tools run.
# Resolve the backend HERE, in the caller's context: under gateway multiplexing the
# routed profile's backend lives in the per-turn terminal scope, which the worker
# thread does not inherit. Remote backends answer "" without consulting the cache
# — the cached line describes the HOST toolchain.
backend = _resolve_terminal_backend()
if backend in _REMOTE_BACKENDS or _plugin_backend_is_remote(backend):
return ""
if _PROBE_DONE.is_set():
return _CACHED_LINE or ""
_ensure_probe_started()
wait_timeout = 0.05 if _WAIT_ALREADY_TIMED_OUT else _PROBE_WAIT_TIMEOUT
if not _PROBE_DONE.wait(timeout=wait_timeout):
# Probe stuck or pathologically slow: the line is a nice-to-have,
# blocking prompt construction is an outage. Fail open.
# blocking prompt construction is an outage. Fail open.
if not _WAIT_ALREADY_TIMED_OUT:
_WAIT_ALREADY_TIMED_OUT = True
logger.warning(
"env_probe did not finish within %.0fs; building the system "
"prompt without the Python toolchain line",
_PROBE_WAIT_TIMEOUT,
)
"prompt without the Python toolchain line", _PROBE_WAIT_TIMEOUT)
return ""
return _CACHED_LINE or ""
@@ -290,26 +215,17 @@ def _ensure_probe_started() -> None:
"""Start the probe worker if it isn't running and hasn't finished."""
global _PROBE_THREAD
with _CACHE_LOCK:
if _PROBE_DONE.is_set():
return
if _PROBE_THREAD is not None and _PROBE_THREAD.is_alive():
if _PROBE_DONE.is_set() or (_PROBE_THREAD is not None and _PROBE_THREAD.is_alive()):
return
_PROBE_THREAD = threading.Thread(
target=_probe_worker,
args=(_PROBE_GEN,),
name="env-probe",
daemon=True,
)
target=_probe_worker, args=(_PROBE_GEN,), name="env-probe", daemon=True)
_PROBE_THREAD.start()
def warm_environment_probe_async() -> None:
"""Start the probe in the background so the first system-prompt build
doesn't pay the ~0.5s of subprocess calls on the time-to-first-token path.
Idempotent and fail-safe; ``get_environment_probe_line`` waits (bounded)
on the same worker instead of recomputing. Called from agent init.
"""
"""Start the probe in the background so the first system-prompt build doesn't
pay the ~0.5s of subprocess calls on the time-to-first-token path. Idempotent;
``get_environment_probe_line`` waits (bounded) on the same worker."""
_ensure_probe_started()

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,18 @@
"""Secret-scrub policy for Hermes child processes: pure data + predicates for
which env names are Hermes-managed credentials. The env *builders* applying it
(``_sanitize_subprocess_env``, ``_make_run_env``, ``hermes_subprocess_env``,
``build_subprocess_env``) live in ``tools.environments.local``."""
"""Secret-scrub policy for Hermes child processes: pure data + predicates for which env
names are Hermes-managed credentials. The env *builders* applying it (``_make_run_env``,
``_sanitize_subprocess_env``, ``hermes_subprocess_env``) live in ``tools.environments.local``."""
import os
# Prefix a caller uses in ``extra_env`` to force a blocklisted var through.
_HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_"
# Hermes-managed AWS *inference* credentials for ``auth_type="aws_sdk"`` (Bedrock).
# Deliberately only the Bedrock bearer token — an inference secret like
# OPENAI_API_KEY that no aws/terraform/boto3 toolchain uses. The general AWS
# credential chain stays inheritable on purpose: the local terminal is the user's
# trusted operator shell (SECURITY.md §3.2), and env_passthrough can never
# re-allow a blocklisted name (GHSA-rhgp-j443-p4rf), so blocking would be
# unrecoverable for every aws/terraform user.
_AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({
"AWS_BEARER_TOKEN_BEDROCK",
})
# Hermes-managed AWS *inference* credentials for ``auth_type="aws_sdk"`` (Bedrock):
# only the Bedrock bearer token, which no aws/terraform/boto3 toolchain uses. The
# general AWS chain stays inheritable on purpose — the local terminal is the user's
# trusted operator shell (SECURITY.md §3.2) and env_passthrough can never re-allow a
# blocklisted name (GHSA-rhgp-j443-p4rf), so blocking it would be unrecoverable.
_AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({"AWS_BEARER_TOKEN_BEDROCK"})
_STATIC_PROVIDER_ENV_BLOCKLIST = frozenset({
"OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_API_BASE", "OPENAI_ORG_ID",
@@ -49,7 +44,6 @@ _STATIC_PROVIDER_ENV_BLOCKLIST = frozenset({
def _build_provider_env_blocklist() -> frozenset:
"""Derive the blocklist from provider, tool, and gateway config."""
blocked: set[str] = set(_STATIC_PROVIDER_ENV_BLOCKLIST)
try:
from hermes_cli.auth import PROVIDER_REGISTRY
for pconfig in PROVIDER_REGISTRY.values():
@@ -60,61 +54,50 @@ def _build_provider_env_blocklist() -> frozenset:
blocked.add(pconfig.base_url_env_var)
except ImportError:
pass
try:
from hermes_cli.config import OPTIONAL_ENV_VARS
for name, metadata in OPTIONAL_ENV_VARS.items():
category = metadata.get("category")
if category in {"tool", "messaging"} or (
category == "setting" and metadata.get("password")
):
category == "setting" and metadata.get("password")):
blocked.add(name)
except ImportError:
pass
# CLAUDE_CODE_OAUTH_TOKEN is owned by the user's Claude Code install, not a
# Hermes credential (subscription auth is not a Hermes provider path).
# Stripping it made agent-spawned ``claude`` CLIs fall through to the shared
# Keychain / ~/.claude credentials store and, on auth failure, wipe it —
# logging the user out. It arrives via the anthropic registry entry above.
# CLAUDE_CODE_OAUTH_TOKEN (via the anthropic registry entry) belongs to the user's
# Claude Code install, not Hermes: stripping it made agent-spawned ``claude`` CLIs
# fall through to the shared Keychain / ~/.claude store and, on auth failure, wipe
# it — logging the user out. BUZZ_* is deliberately NOT discarded: this list feeds
# every scrub surface, so an import-time discard would leak BUZZ_PRIVATE_KEY into
# non-terminal children; the Buzz carve-out is terminal-only and context-gated
# (``_is_terminal_first_party_env``).
blocked.discard("CLAUDE_CODE_OAUTH_TOKEN")
# BUZZ_* is deliberately NOT discarded, even for Buzz-managed agents: this
# blocklist feeds every scrub surface (terminal, execute_code, the
# hermes_subprocess_env Tier-2 strip), so an import-time discard would leak
# BUZZ_PRIVATE_KEY into non-terminal children. The Buzz carve-out is a
# terminal-only, context-gated scrub-path exemption — see
# ``_is_terminal_first_party_env``.
return frozenset(blocked)
_HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist()
# First-party platform credentials (``BUZZ_*``, driving the platform-mandated
# ``buzz`` CLI) carved out of the TERMINAL scrub only (``_make_run_env``,
# First-party platform credentials (``BUZZ_*``, driving the platform-mandated ``buzz``
# CLI) carved out of the TERMINAL scrub only (``_make_run_env``,
# ``_sanitize_subprocess_env``); execute_code, hermes_subprocess_env, docker and
# env_passthrough registration stay sealed, so GHSA-rhgp-j443-p4rf holds.
# CONTEXT-GATED (``_buzz_terminal_context_active``): a Telegram/CLI/cron session
# on a host that also runs a Buzz gateway must not get the signing key. Values
# are used directly, never scope-resolved (UnscopedSecretError under multiplex),
# and the snapshot treats them as profile-scoped so they never persist across
# profiles. Prefix-based so future BUZZ_* names need no code change.
# env_passthrough registration stay sealed (GHSA-rhgp-j443-p4rf). CONTEXT-GATED via
# ``_buzz_terminal_context_active``: a Telegram/CLI/cron session on a host that also
# runs a Buzz gateway must not get the signing key. Values are used directly, never
# scope-resolved (UnscopedSecretError under multiplex); the snapshot treats them as
# profile-scoped. Prefix-based so future BUZZ_* names need no code change.
_TERMINAL_FIRST_PARTY_ENV_PREFIXES = ("BUZZ_",)
def _matches_terminal_first_party_prefix(name: str) -> bool:
"""Pure name check (``BUZZ_*``), regardless of session context — the
snapshot exclusion must stay conservative even when the carve-out is inactive."""
"""Pure name check (``BUZZ_*``), regardless of session context — the snapshot
exclusion must stay conservative even when the carve-out is inactive."""
return name.startswith(_TERMINAL_FIRST_PARTY_ENV_PREFIXES)
def _buzz_terminal_context_active() -> bool:
"""True when this process/session operates as a Buzz agent.
Either signal suffices: ``BUZZ_MANAGED_AGENT`` in the process env (set only
by Buzz Desktop's buzz-acp harness), or the live session's platform is
``buzz`` via the gateway ContextVar — authoritative under a concurrent
multi-session host, so a sibling Telegram session resolves its OWN platform.
"""
"""True when this process/session operates as a Buzz agent: ``BUZZ_MANAGED_AGENT`` in
the process env (set only by Buzz Desktop's buzz-acp harness), or the live session's
platform is ``buzz`` via the gateway ContextVar — authoritative under a concurrent
multi-session host, so a sibling Telegram session resolves its OWN platform."""
if os.environ.get("BUZZ_MANAGED_AGENT"):
return True
try:
@@ -126,26 +109,24 @@ def _buzz_terminal_context_active() -> bool:
def _is_terminal_first_party_env(name: str) -> bool:
"""``name`` is a first-party platform credential (``BUZZ_*``) AND the
current process/session context entitles it to reach terminal children."""
"""``name`` is a first-party platform credential (``BUZZ_*``) AND the current
process/session context entitles it to reach terminal children."""
return _matches_terminal_first_party_prefix(name) and _buzz_terminal_context_active()
# Active-venv markers that must NOT leak: a leaked VIRTUAL_ENV/CONDA_PREFIX makes
# uv/poetry sync ANOTHER project's deps into the Hermes venv (clobbering it; the
# venv stays reachable via PATH so stripping is safe), and PYTHONHOME redirects
# any child interpreter's stdlib to the Hermes venv (version-mismatch crashes).
# PYTHONPATH is handled separately — only Hermes-owned entries are removed.
# Active-venv markers that must NOT leak: VIRTUAL_ENV/CONDA_PREFIX make uv/poetry sync
# ANOTHER project's deps into the Hermes venv (still reachable via PATH, so stripping
# is safe); PYTHONHOME redirects a child interpreter's stdlib to the Hermes venv
# (version-mismatch crashes). PYTHONPATH is handled separately (Hermes-owned entries only).
_ACTIVE_VENV_MARKER_VARS = ("VIRTUAL_ENV", "CONDA_PREFIX", "PYTHONHOME")
def _is_hermes_internal_secret(key: str) -> bool:
"""True for Hermes-internal secrets injected under *dynamic* names the
static blocklist cannot enumerate: ``AUXILIARY_<TASK>_API_KEY``/``_BASE_URL``
(per-task side-LLM credentials) and ``GATEWAY_RELAY_*_SECRET``/``_KEY``/
``_TOKEN`` (relay auth; non-secret routing hints stay visible). Single source
of truth for every spawn path, stripped regardless of env_passthrough
registration or ``inherit_credentials``."""
"""True for Hermes-internal secrets injected under *dynamic* names the static
blocklist cannot enumerate: ``AUXILIARY_<TASK>_API_KEY``/``_BASE_URL`` (per-task
side-LLM credentials) and ``GATEWAY_RELAY_*_SECRET``/``_KEY``/``_TOKEN`` (relay
auth; non-secret routing hints stay visible). Stripped on every spawn path
regardless of env_passthrough registration or ``inherit_credentials``."""
upper = key.upper()
if upper.startswith("AUXILIARY_") and upper.endswith(("_API_KEY", "_BASE_URL")):
return True
@@ -153,11 +134,9 @@ def _is_hermes_internal_secret(key: str) -> bool:
def _plugin_terminal_env_strip_keys() -> frozenset:
"""Credential env keys owned by plugin-registered terminal backends.
Computed at call time because plugins register after import. Tier-1:
stripped from every spawned subprocess unconditionally. Fail-soft to empty.
"""
"""Credential env keys owned by plugin-registered terminal backends (Tier-1:
stripped from every spawned subprocess). Computed at call time because plugins
register after import; fail-soft to empty."""
try:
from agent.terminal_env_registry import plugin_strip_env_keys
@@ -166,10 +145,9 @@ def _plugin_terminal_env_strip_keys() -> frozenset:
return frozenset()
# Tier-1 secrets: stripped from EVERY spawned subprocess even under
# inherit_credentials (claude/codex/gemini). Not provider credentials — no child
# needs them and they are the highest-value secrets to keep from a compromised
# dependency. Provider keys are the conditional Tier-2 strip.
# Tier-1 secrets: stripped from EVERY spawned subprocess even under inherit_credentials
# (claude/codex/gemini). Not provider credentials — no child needs them and they are the
# highest-value secrets to keep from a compromised dependency. Provider keys = Tier 2.
_ALWAYS_STRIP_KEYS: frozenset[str] = frozenset({
# GitHub auth
"GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH",

View File

@@ -37,34 +37,19 @@ def _mandatory_aslr_enabled() -> "bool | None":
global _mandatory_aslr_enabled_cache
if _mandatory_aslr_enabled_cache is not None:
return _mandatory_aslr_enabled_cache
cmd = [shutil.which("powershell.exe") or "powershell.exe", "-NoProfile", "-NonInteractive",
"-Command", "(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()"]
try:
powershell = shutil.which("powershell.exe") or "powershell.exe"
result = subprocess.run(
[
powershell,
"-NoProfile",
"-NonInteractive",
"-Command",
"(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()",
],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=10,
creationflags=windows_hide_flags(),
)
if result.returncode != 0:
return None
value = (result.stdout or "").strip().upper()
if value == "ON":
_mandatory_aslr_enabled_cache = True
return True
if value in {"OFF", "NOTSET"}:
_mandatory_aslr_enabled_cache = False
return False
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=10, creationflags=windows_hide_flags())
except Exception as exc:
logger.debug("Could not query Windows Mandatory ASLR state: %s", exc)
return None
return None
if result.returncode != 0:
return None
value = (result.stdout or "").strip().upper()
_mandatory_aslr_enabled_cache = {"ON": True, "OFF": False, "NOTSET": False}.get(value)
return _mandatory_aslr_enabled_cache
def _git_root_from_bash(bash: str) -> str:
@@ -73,15 +58,12 @@ def _git_root_from_bash(bash: str) -> str:
if ntpath.basename(bin_dir).lower() != "bin":
return ntpath.dirname(bin_dir)
parent = ntpath.dirname(bin_dir)
if ntpath.basename(parent).lower() == "usr":
return ntpath.dirname(parent)
return parent
return ntpath.dirname(parent) if ntpath.basename(parent).lower() == "usr" else parent
def _git_bash_aslr_help(bash: str, details: str = "") -> str:
"""Build the targeted per-program Mandatory-ASLR remediation."""
git_root = _git_root_from_bash(bash)
escaped_root = git_root.replace("'", "''")
escaped_root = _git_root_from_bash(bash).replace("'", "''")
detail_line = f"\nGit Bash probe output: {details[:500]}" if details else ""
return (
f"Git Bash at {bash} cannot launch required MSYS child processes while "
@@ -102,27 +84,21 @@ def _bash_starts(bash: str) -> bool:
"""True if *bash* can launch external MSYS programs (cached per path).
``--noprofile --norc`` so a broken login post-install (``Directory
\\drivers\\etc``) does not falsely condemn an otherwise usable bash."""
cached = _bash_starts_cache.get(bash)
if cached is not None:
return cached
if bash in _bash_starts_cache:
return _bash_starts_cache[bash]
try:
result = subprocess.run(
[bash, "--noprofile", "--norc", "-c", _BASH_EXTERNAL_PROGRAM_PROBE],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=15,
creationflags=windows_hide_flags() if _IS_WINDOWS else 0,
)
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=15, creationflags=windows_hide_flags() if _IS_WINDOWS else 0)
ok = result.returncode == 0
if not ok:
combined = f"{result.stdout or ''}{result.stderr or ''}"
_bash_probe_details_cache[bash] = combined.strip()[:2000]
logger.debug("bash probe failed for %s: %s", bash, combined.strip()[:200])
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
_bash_probe_details_cache[bash] = combined[:2000]
logger.debug("bash probe failed for %s: %s", bash, combined[:200])
except Exception as exc:
_bash_probe_details_cache[bash] = str(exc)[:2000]
logger.debug("bash probe error for %s: %s", bash, exc)
ok = False
_bash_starts_cache[bash] = ok
return ok

View File

@@ -1,14 +1,10 @@
"""Hermes-owned PYTHONPATH stripping for child processes.
Launchers prepend the repo root and the Hermes venv's site-packages so the
backend can ``import tools``; leaked into a child Python of a DIFFERENT version
they load the backend's C extensions and crash (numpy, PIL, cryptography). Only
entries proven Hermes-owned by *path provenance* are removed — never by a
cross-version heuristic — so user entries survive. Module state
(``_hermes_repo_root_aliases``, ``_in_venv``, ``_hermes_site_packages``) stays
in ``tools.environments.local`` and is read via :func:`_state` so tests that
monkeypatch it there keep working.
"""
"""Hermes-owned PYTHONPATH stripping for child processes. Launchers prepend the repo
root and the Hermes venv's site-packages so the backend can ``import tools``; leaked
into a child Python of a DIFFERENT version they load the backend's C extensions and
crash. Only entries proven Hermes-owned by *path provenance* are removed — never by a
cross-version heuristic. Module state (``_hermes_repo_root_aliases``, ``_in_venv``,
``_hermes_site_packages``) lives in ``tools.environments.local`` (via :func:`_state`)
so tests monkeypatching it there keep working."""
import logging
import os
@@ -32,162 +28,113 @@ def _state():
def _same_path(left: Path, right: Path) -> bool:
"""Compare path spellings with host filesystem case semantics."""
left_parts = [os.path.normcase(part) for part in left.parts]
right_parts = [os.path.normcase(part) for part in right.parts]
return left_parts == right_parts
return [os.path.normcase(p) for p in left.parts] == [os.path.normcase(p) for p in right.parts]
def _build_hermes_repo_root_aliases(
resolved_root: Path,
lexical_root: Path,
configured_home: Path,
resolved_root: Path, lexical_root: Path, configured_home: Path,
) -> tuple[Path, ...]:
"""Exact repo-root spellings emitted by Hermes launchers. Mirrors
``gateway_windows._preserve_hermes_home_path`` (physical path under the
resolved HERMES_HOME -> configured spelling) so a junction-backed install
matches without treating arbitrary HERMES_HOME descendants as Hermes-owned.
A repo-level junction (possibly cross-drive) is accepted only when a strict
resolve proves <root>/<repo dirname> is the physical root (fail-closed)."""
aliases: list[Path] = []
def add(candidate: Path) -> None:
if not any(_same_path(candidate, existing) for existing in aliases):
aliases.append(candidate)
add(resolved_root)
add(lexical_root)
``gateway_windows._preserve_hermes_home_path`` (physical path under the resolved
HERMES_HOME -> configured spelling) so a junction-backed install matches without
treating arbitrary HERMES_HOME descendants as Hermes-owned. A repo-level junction
(possibly cross-drive) is accepted only when a strict resolve proves
<root>/<repo dirname> is the physical root (fail-closed)."""
candidates = [resolved_root, lexical_root]
# Profile re-home: with --profile the configured home is <root>/profiles/<name>
# and the repo lives beside the profiles dir, so derive the root lexically the
# same way get_default_hermes_root() does and map against it too.
# and the repo lives beside the profiles dir (as get_default_hermes_root() does).
home_candidates = [configured_home]
if configured_home.parent.name == "profiles":
home_candidates.append(configured_home.parent.parent)
for home in home_candidates:
try:
resolved_home = home.resolve()
home_key = os.path.normcase(str(resolved_home))
root_key = os.path.normcase(str(resolved_root))
if os.path.commonpath([home_key, root_key]) == home_key:
relative_root = os.path.relpath(str(resolved_root), str(resolved_home))
add(home / relative_root)
if os.path.commonpath([home_key, os.path.normcase(str(resolved_root))]) == home_key:
candidates.append(home / os.path.relpath(str(resolved_root), str(resolved_home)))
except (OSError, ValueError):
pass
# Repo-level junction recovery (commonpath raises across drives, so the
# home-relative mapping above cannot express a cross-drive link).
for home in home_candidates:
repo_candidate = home / resolved_root.name
try:
if repo_candidate.resolve(strict=True) == resolved_root.resolve(strict=True):
add(repo_candidate)
candidates.append(repo_candidate)
except OSError:
pass
aliases: list[Path] = []
for candidate in candidates:
if not any(_same_path(candidate, existing) for existing in aliases):
aliases.append(candidate)
return tuple(aliases)
def _validated_runtime_venv(env: dict) -> Path | None:
"""Producer-owned runtime venv identified by VIRTUAL_ENV, or None. The
variable alone is not provenance (users carry unrelated venvs): require the
legacy Windows base-Python producer's exact ``<repo>/venv`` layout AND a
real ``pyvenv.cfg``."""
value = env.get("VIRTUAL_ENV")
if not value:
"""Producer-owned runtime venv identified by VIRTUAL_ENV, or None. The variable
alone is not provenance (users carry unrelated venvs): require the legacy Windows
base-Python producer's exact ``<repo>/venv`` layout AND a real ``pyvenv.cfg``."""
candidate = Path(env.get("VIRTUAL_ENV") or "")
if not env.get("VIRTUAL_ENV") or not any(
_same_path(candidate, root / "venv") for root in _state()._hermes_repo_root_aliases):
return None
candidate = Path(value)
aliases = _state()._hermes_repo_root_aliases
if not any(_same_path(candidate, repo_root / "venv") for repo_root in aliases):
return None
try:
if not (candidate / "pyvenv.cfg").is_file():
return None
return candidate if (candidate / "pyvenv.cfg").is_file() else None
except OSError:
return None
return candidate
def _get_hermes_site_packages(env: dict) -> list[Path]:
"""Exact site-packages dirs owned by the Hermes runtime (cached):
``site.getsitepackages()`` with a ``sys.prefix`` fallback, plus a validated
Windows base-interpreter launch's ``VIRTUAL_ENV/Lib/site-packages``."""
local = _state()
if local._hermes_site_packages is not None:
result = list(local._hermes_site_packages)
else:
result = []
if local._hermes_site_packages is None:
result: list[Path] = []
if local._in_venv:
try:
import site
for sp in site.getsitepackages():
result.append(Path(sp))
result.extend(Path(sp) for sp in site.getsitepackages())
except Exception:
pass
if not result:
if _IS_WINDOWS:
result.append(Path(sys.prefix) / "Lib" / "site-packages")
else:
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
result.append(Path(sys.prefix) / "lib" / pyver / "site-packages")
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
result.append(Path(sys.prefix) / "Lib" / "site-packages" if _IS_WINDOWS
else Path(sys.prefix) / "lib" / pyver / "site-packages")
local._hermes_site_packages = list(result)
result = list(local._hermes_site_packages)
runtime_venv = _validated_runtime_venv(env)
if runtime_venv is not None:
runtime_site_packages = runtime_venv / "Lib" / "site-packages"
if not any(_same_path(runtime_site_packages, existing) for existing in result):
result.append(runtime_site_packages)
return result
def _strip_hermes_owned_pythonpath_and_runtime_markers(env: dict) -> None:
"""Strip Hermes-owned PYTHONPATH entries, then the runtime marker vars.
Ordering is load-bearing: PYTHONPATH filtering must run BEFORE the markers
are removed so a validated Windows base-interpreter launch
(VIRTUAL_ENV -> <repo>/venv) can still prove ownership.
"""
"""Strip Hermes-owned PYTHONPATH entries, then the runtime marker vars. Order is
load-bearing: PYTHONPATH filtering runs BEFORE the markers go so a validated Windows
base-interpreter launch (VIRTUAL_ENV -> <repo>/venv) can still prove ownership."""
_strip_hermes_owned_pythonpath(env)
for _marker in _ACTIVE_VENV_MARKER_VARS:
env.pop(_marker, None)
def _strip_hermes_owned_pythonpath(env: dict) -> None:
"""Remove Hermes-owned PYTHONPATH entries. Only exact matches of the repo
root (any launcher spelling) and runtime site-packages are stripped — never
children/descendants, which are user paths. Empty components (= cwd) and
everything else are preserved byte-for-byte."""
"""Remove Hermes-owned PYTHONPATH entries: only exact matches of the repo root
(any launcher spelling) and runtime site-packages — never descendants, which are
user paths. Empty components (= cwd) and everything else are preserved."""
pp = env.get("PYTHONPATH")
if not pp:
return
hermes_site_packages = _get_hermes_site_packages(env)
repo_roots = _state()._hermes_repo_root_aliases
kept: list[str] = []
stripped: list[str] = []
for entry in pp.split(os.pathsep):
if entry == "":
kept.append(entry)
continue
entry_path = Path(entry)
owned = any(_same_path(entry_path, sp) for sp in hermes_site_packages) or any(
_same_path(entry_path, repo_root) for repo_root in repo_roots
)
(stripped if owned else kept).append(entry)
owned_paths = [*_get_hermes_site_packages(env), *_state()._hermes_repo_root_aliases]
entries = pp.split(os.pathsep)
stripped = [e for e in entries if e and any(_same_path(Path(e), p) for p in owned_paths)]
kept = [e for e in entries if e not in stripped]
if kept:
env["PYTHONPATH"] = os.pathsep.join(kept)
else:
env.pop("PYTHONPATH", None)
if stripped:
logger.debug("Stripped Hermes-owned entries from PYTHONPATH: %s", stripped)