hermes-agent/tools/env_passthrough.py
Teknium ba4357d13b
fix(env_passthrough): reject Hermes provider credentials from skill passthrough (#13523)
A skill declaring `required_environment_variables: [ANTHROPIC_TOKEN]` in
its SKILL.md frontmatter silently bypassed the `execute_code` sandbox's
credential-scrubbing guarantee. `register_env_passthrough` had no
blocklist, so any name a skill chose flipped `is_env_passthrough(name) =>
True`, which shortcircuits the sandbox's secret filter.

Fix: reject registration when the name appears in
`_HERMES_PROVIDER_ENV_BLOCKLIST` (the canonical list of Hermes-managed
credentials — provider keys, gateway tokens, etc.). Log a warning naming
GHSA-rhgp-j443-p4rf so operators see the rejection in logs.

Non-Hermes third-party API keys (TENOR_API_KEY for gif-search,
NOTION_TOKEN for notion skills, etc.) remain legitimately registerable —
they were never in the sandbox scrub list in the first place.

Tests: 16 -> 17 passing. Two old tests that documented the bypass
(`test_passthrough_allows_blocklisted_var`, `test_make_run_env_passthrough`)
are rewritten to assert the new fail-closed behavior. New
`test_non_hermes_api_key_still_registerable` locks in that legitimate
third-party keys are unaffected.

Reported in GHSA-rhgp-j443-p4rf by @q1uf3ng. Hardening; not CVE-worthy
on its own per the decision matrix (attacker must already have operator
consent to install a malicious skill).
2026-04-21 06:14:25 -07:00

144 lines
5.4 KiB
Python

"""Environment variable passthrough registry.
Skills that declare ``required_environment_variables`` in their frontmatter
need those vars available in sandboxed execution environments (execute_code,
terminal). By default both sandboxes strip secrets from the child process
environment for security. This module provides a session-scoped allowlist
so skill-declared vars (and user-configured overrides) pass through.
Two sources feed the allowlist:
1. **Skill declarations** — when a skill is loaded via ``skill_view``, its
``required_environment_variables`` are registered here automatically.
2. **User config** — ``terminal.env_passthrough`` in config.yaml lets users
explicitly allowlist vars for non-skill use cases.
Both ``code_execution_tool.py`` and ``tools/environments/local.py`` consult
:func:`is_env_passthrough` before stripping a variable.
"""
from __future__ import annotations
import logging
from contextvars import ContextVar
from typing import Iterable
logger = logging.getLogger(__name__)
# Session-scoped set of env var names that should pass through to sandboxes.
# Backed by ContextVar to prevent cross-session data 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
# Cache for the config-based allowlist (loaded once per process).
_config_passthrough: frozenset[str] | None = None
def _is_hermes_provider_credential(name: str) -> bool:
"""True if ``name`` is a Hermes-managed provider credential (API key,
token, or similar) per ``_HERMES_PROVIDER_ENV_BLOCKLIST``.
Skill-declared ``required_environment_variables`` frontmatter must
not be able to override this list — that was the bypass in
GHSA-rhgp-j443-p4rf where a malicious skill registered
``ANTHROPIC_TOKEN`` / ``OPENAI_API_KEY`` as passthrough and received
the credential in the ``execute_code`` child process, defeating the
sandbox's scrubbing guarantee.
Non-Hermes API keys (TENOR_API_KEY, NOTION_TOKEN, etc.) are NOT
in the blocklist and remain legitimately registerable — skills that
wrap third-party APIs still work.
"""
try:
from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST
except Exception:
return False
return name in _HERMES_PROVIDER_ENV_BLOCKLIST
def register_env_passthrough(var_names: Iterable[str]) -> None:
"""Register environment variable names as allowed in sandboxed environments.
Typically called when a skill declares ``required_environment_variables``.
Variables that are Hermes-managed provider credentials (from
``_HERMES_PROVIDER_ENV_BLOCKLIST``) are rejected here to preserve
the ``execute_code`` sandbox's credential-scrubbing guarantee per
GHSA-rhgp-j443-p4rf. A skill that needs to talk to a Hermes-managed
provider should do so via the agent's main-process tools (web_search,
web_extract, etc.) where the credential remains safely in the main
process.
Non-Hermes third-party API keys (TENOR_API_KEY, NOTION_TOKEN, etc.)
pass through normally — they were never in the sandbox scrub list.
"""
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
_get_allowed().add(name)
logger.debug("env passthrough: registered %s", name)
def _load_config_passthrough() -> frozenset[str]:
"""Load ``tools.env_passthrough`` from config.yaml (cached)."""
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("terminal", {}).get("env_passthrough")
if isinstance(passthrough, list):
for item in passthrough:
if isinstance(item, str) and item.strip():
result.add(item.strip())
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:
"""Check whether *var_name* is allowed to pass through to sandboxes.
Returns ``True`` if the variable was registered by a skill or listed in
the user's ``tools.env_passthrough`` config.
"""
if var_name in _get_allowed():
return True
return 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 clear_env_passthrough() -> None:
"""Reset the skill-scoped allowlist (e.g. on session reset)."""
_get_allowed().clear()