Merge branch 'simp/r3-32-E' into simp/r3-32
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user