refactor(tools): compact local env builders, env_probe, env_passthrough, pythonpath/gitbash helpers; fold force-unwrap and kill-group flows

This commit is contained in:
Teknium
2026-09-02 22:24:19 -07:00
parent 113f04616b
commit 8b249e4b3d
7 changed files with 420 additions and 752 deletions

View File

@@ -720,6 +720,7 @@ class TestLocalEnvironmentWindowsTempDir:
assert "if _IS_WINDOWS:" in source
assert "get_hermes_home" in source
assert 'cache_dir = get_hermes_home() / "cache" / "terminal"' in source
assert "_default_terminal_temp_dir() or Path(tempfile.gettempdir())" in source
class TestLocalEnvironmentPathInjectionGated:

View File

@@ -1,16 +1,11 @@
"""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.
Skills that declare ``required_environment_variables`` need those vars in sandboxed
execution environments (execute_code, terminal), which strip secrets from the child
env by default. This module is the session-scoped allowlist, fed by skill
declarations (registered by ``skill_view``) and ``terminal.env_passthrough`` in
config.yaml. When profile multiplexing is active, forwarded values are resolved
through the current profile's secret scope rather than the process environment.
"""
from __future__ import annotations
@@ -43,18 +38,17 @@ _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).
``_HERMES_PROVIDER_ENV_BLOCKLIST`` or a dynamic Hermes-internal secret
(AUXILIARY_*_API_KEY / _BASE_URL, GATEWAY_RELAY_*, injected per task/relay so
the static list can't enumerate them).
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.
Skill-declared ``required_environment_variables`` must not override this list —
that was the GHSA-rhgp-j443-p4rf bypass (a malicious skill registered
``OPENAI_API_KEY`` as passthrough and received it in the ``execute_code`` child).
Non-Hermes API keys (TENOR_API_KEY, NOTION_TOKEN, …) 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.
Fail closed: if the authoritative blocklist cannot be imported (partial install,
import-time error), treat the name as protected.
"""
try:
from tools.environments.local import (
@@ -69,12 +63,7 @@ def _is_hermes_provider_credential(name: str) -> bool:
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:
@@ -82,10 +71,9 @@ def register_env_passthrough(var_names: Iterable[str]) -> None:
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.
``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, …). Third-party keys pass normally.
"""
for name in var_names:
name = name.strip()
@@ -105,7 +93,9 @@ def register_env_passthrough(var_names: Iterable[str]) -> None:
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
@@ -113,28 +103,23 @@ def _load_config_passthrough() -> frozenset[str]:
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")
for item in passthrough if isinstance(passthrough, list) else ():
name = item.strip() if isinstance(item, str) else ""
if not name:
continue
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)
except Exception as e:
logger.debug("Could not read tools.env_passthrough from config: %s", e)
@@ -144,9 +129,7 @@ def _load_config_passthrough() -> frozenset[str]:
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,20 +137,16 @@ 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:
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.
``fallback`` is the value the caller would have forwarded before profile secret
scopes existed (a snapshot of ``os.environ`` or the 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.
"""
from agent.secret_scope import (
_is_global_env,
@@ -176,19 +155,14 @@ def resolve_passthrough_value(
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.
# 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,15 +1,10 @@
"""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``).
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, PEP 668) so models don't discover it by hitting walls. Cheap
(~50ms), cached for the process lifetime, "" when the environment is clean.
Remote backends are skipped (the sandbox has its own probe in agent/prompt_builder).
Toggle via ``agent.environment_probe`` in config.yaml (default True).
"""
@@ -27,24 +22,19 @@ 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.
# ``_PROBE_DONE`` signals completion. Callers never execute the probe themselves
# and block at most ``_PROBE_WAIT_TIMEOUT`` seconds 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.
_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.
# Bumped on every reset so a stale worker can't publish 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.
# Upper bound a prompt build will wait for the probe (healthy runtime ~0.5s).
_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.
# Once one caller has burned the full wait, later callers only peek at the event.
_WAIT_ALREADY_TIMED_OUT = False
# Keep in sync with agent/prompt_builder.py:_REMOTE_TERMINAL_BACKENDS.
@@ -68,33 +58,24 @@ 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).
"""Run a short subprocess -> (returncode, stdout, stderr); failures (binary
missing, timeout, OSError) return (-1, "", "<reason>").
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.
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
have no reader threads, so ``wait()`` only waits on the direct 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,
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.
# gateway / kanban workers) would otherwise flash a console.
creationflags=windows_hide_flags(),
)
except subprocess.TimeoutExpired:
@@ -122,8 +103,7 @@ 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 _run([binary, "-m", "pip", "--version"])[0] == 0
def _detect_pep668(binary: str) -> bool:
@@ -142,17 +122,12 @@ def _detect_pep668(binary: str) -> bool:
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 ``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."""
if not shutil.which("pip"):
return None
rc, out, _err = _run(["pip", "--version"])
if rc != 0 or not out:
return None
if "(python " in out and out.endswith(")"):
if rc == 0 and out and "(python " in out and out.endswith(")"):
return out.rsplit("(python ", 1)[1][:-1].strip()
return None
@@ -169,37 +144,26 @@ 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 (via
# local.py) includes the Hermes-managed $HERMES_HOME/bin.
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")
@@ -213,41 +177,31 @@ def _build_probe_line() -> str:
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
# bare worker thread does not inherit. A remote backend answers "" 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 ""
@@ -259,7 +213,7 @@ def get_environment_probe_line(*, force_refresh: bool = False) -> str:
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(
@@ -290,26 +244,18 @@ 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

@@ -8,13 +8,12 @@ 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.
# Hermes-managed AWS *inference* credentials for ``auth_type="aws_sdk"`` (Bedrock):
# deliberately only the Bedrock bearer token, which 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 it would
# be unrecoverable for every aws/terraform user.
_AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({
"AWS_BEARER_TOKEN_BEDROCK",
})
@@ -49,7 +48,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,7 +58,6 @@ 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():
@@ -71,50 +68,44 @@ def _build_provider_env_blocklist() -> frozenset:
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 (arrives via the anthropic registry entry) is owned by
# the user's Claude Code install, not a Hermes credential. 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.
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``.
# BUZZ_* is deliberately NOT discarded: this blocklist feeds every scrub surface
# (terminal, execute_code, hermes_subprocess_env Tier-2), so an import-time
# discard would leak BUZZ_PRIVATE_KEY into non-terminal children. The Buzz
# carve-out is a terminal-only, context-gated exemption
# (``_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), and 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 +117,25 @@ 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.
# uv/poetry sync ANOTHER project's deps into the Hermes venv (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_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 +143,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

View File

@@ -37,31 +37,24 @@ def _mandatory_aslr_enabled() -> "bool | None":
global _mandatory_aslr_enabled_cache
if _mandatory_aslr_enabled_cache is not None:
return _mandatory_aslr_enabled_cache
try:
powershell = shutil.which("powershell.exe") or "powershell.exe"
result = subprocess.run(
[
powershell,
"-NoProfile",
"-NonInteractive",
"-Command",
shutil.which("powershell.exe") or "powershell.exe",
"-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(),
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"}:
elif value in {"OFF", "NOTSET"}:
_mandatory_aslr_enabled_cache = False
return False
return _mandatory_aslr_enabled_cache
except Exception as exc:
logger.debug("Could not query Windows Mandatory ASLR state: %s", exc)
return None
@@ -73,15 +66,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 "
@@ -105,24 +95,20 @@ def _bash_starts(bash: str) -> bool:
cached = _bash_starts_cache.get(bash)
if cached is not None:
return cached
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,13 +1,12 @@
"""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.
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``) stays in
``tools.environments.local`` (read via :func:`_state`) so tests monkeypatching it
there keep working.
"""
import logging
@@ -32,22 +31,18 @@ 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)."""
``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:
@@ -58,8 +53,7 @@ def _build_hermes_repo_root_aliases(
add(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)
@@ -68,10 +62,8 @@ def _build_hermes_repo_root_aliases(
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:
add(home / os.path.relpath(str(resolved_root), str(resolved_home)))
except (OSError, ValueError):
pass
@@ -89,105 +81,77 @@ def _build_hermes_repo_root_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``."""
"""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:
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):
if not any(_same_path(candidate, root / "venv") for root in _state()._hermes_repo_root_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")
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. Ordering
is load-bearing: PYTHONPATH filtering runs BEFORE the markers are removed 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
owned_paths = [*_get_hermes_site_packages(env), *_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
)
owned = entry != "" and any(_same_path(Path(entry), p) for p in owned_paths)
(stripped if owned else kept).append(entry)
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)