The provider-credential blocklist and every adjacent env-name check ran case-sensitive membership tests while the Windows environment block resolves names case-insensitively. A skill or terminal.env_passthrough entry registering openai_api_key was accepted, then resolved to the real OPENAI_API_KEY by os.getenv() and forwarded into SSH/Docker exec envs, the same GHSA-rhgp-j443-p4rf tunnel the blocklist closes. The same gap let variant-cased credentials survive the source-side strip (_filter_secret_env, _scrub_credentials), evade the docker_forward_env and docker_extra_args egress collision guards, and ride strip_launch_profile_env residue into a routed sibling profile's child. Add _is_provider_env_blocklisted (exact + folded membership) and apply it at every layer: registration refusal, both scrub paths (Tier-1 and plugin strip sets fold too; inherit_credentials still inherits), the remote exec-env builder, and the docker egress collision checks. Fold _matches_terminal_first_party_prefix symmetrically so a lowercase-stored buzz_private_key keeps its terminal carve-out, and fold the launch- residue pop (selection folds too so a lowercase path in .env stays global). _HERMES_FORCE_* opt-in transport and docker_env literal container names stay case-sensitive on purpose.
173 lines
8.0 KiB
Python
173 lines
8.0 KiB
Python
"""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, read_raw_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Session-scoped allowlist; ContextVar-backed to prevent cross-session bleed
|
|
# in the gateway pipeline.
|
|
_allowed_env_vars_var: ContextVar[set[str]] = ContextVar("_allowed_env_vars")
|
|
|
|
|
|
def _get_allowed() -> set[str]:
|
|
"""Get or create the allowed env vars set for the current context/session."""
|
|
try:
|
|
return _allowed_env_vars_var.get()
|
|
except LookupError:
|
|
val: set[str] = set()
|
|
_allowed_env_vars_var.set(val)
|
|
return val
|
|
|
|
|
|
# Config-based allowlist, keyed by Hermes home: under gateway.multiplex_profiles one process serves
|
|
# many profiles, and a single slot would let the first profile's operator allowlist decide which env
|
|
# vars tunnel into every other profile's sandbox children.
|
|
_config_passthrough: dict[str, frozenset[str]] = {}
|
|
|
|
|
|
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
|
|
(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_env_policy import (
|
|
_is_hermes_internal_secret, _is_provider_env_blocklisted)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"env passthrough: provider credential blocklist import failed; "
|
|
"failing closed and refusing passthrough registration for %r: %s", name, e)
|
|
return True
|
|
# Case-folded membership too: the remote-exec env builder resolves each
|
|
# registered name via os.getenv(), which is case-insensitive on Windows, so
|
|
# ``openai_api_key`` would tunnel the real OPENAI_API_KEY into children.
|
|
return _is_hermes_internal_secret(name) or _is_provider_env_blocklisted(name)
|
|
|
|
|
|
def register_env_passthrough(var_names: Iterable[str]) -> None:
|
|
"""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). Same credential
|
|
filter as register_env_passthrough: operator config must not tunnel provider
|
|
credentials into sandbox children either (GHSA-rhgp-j443-p4rf)."""
|
|
from hermes_constants import hermes_home_key
|
|
|
|
try:
|
|
home_key = hermes_home_key()
|
|
except (RuntimeError, OSError):
|
|
# No resolvable home (stripped environ in a sandbox child): nothing to scope by.
|
|
home_key = ""
|
|
cached = _config_passthrough.get(home_key)
|
|
if cached is not None:
|
|
return cached
|
|
result: set[str] = set()
|
|
try:
|
|
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[home_key] = frozenset(result)
|
|
return _config_passthrough[home_key]
|
|
|
|
|
|
def is_env_passthrough(var_name: str) -> bool:
|
|
"""True if *var_name* was registered by a skill or listed in config."""
|
|
return var_name in _get_allowed() or var_name in _load_config_passthrough()
|
|
|
|
|
|
def get_all_passthrough() -> frozenset[str]:
|
|
"""Return the union of skill-registered and config-based passthrough vars."""
|
|
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 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 (incl. an explicit per-call override).
|
|
if _is_global_env(name) and fallback is not None:
|
|
return fallback
|
|
multiplex_active = is_multiplex_active()
|
|
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)
|
|
|
|
|
|
def scoped_passthrough_additions(present: Iterable[str]) -> dict[str, str]:
|
|
"""Declared passthrough names the bound profile secret scope supplies but the env being
|
|
filtered (*present*) lacks. A routed profile's ``.env`` and hydrated sources never enter
|
|
``os.environ`` (``load_hermes_dotenv`` skips the process-global load for a routed home), so a
|
|
name-by-name filter over the process env can only forward a declared name the LAUNCH profile
|
|
also happens to define — the served profile's own value has no way in (#114209). Reads the
|
|
bound scope alone: never ``os.environ``, never another profile. Empty without a scope, so
|
|
single-profile spawns are byte-identical."""
|
|
from agent.secret_scope import _is_global_env, current_secret_scope
|
|
scope = current_secret_scope()
|
|
if not scope:
|
|
return {}
|
|
present = set(present)
|
|
additions: dict[str, str] = {}
|
|
for name in get_all_passthrough():
|
|
if name in present or _is_global_env(name):
|
|
continue
|
|
value = scope.get(name)
|
|
if value is not None:
|
|
additions[name] = value
|
|
return additions
|
|
|
|
|
|
def clear_env_passthrough() -> None:
|
|
"""Reset the skill-scoped allowlist (e.g. on session reset)."""
|
|
_get_allowed().clear()
|