Files
hermes-agent/tools/environments/docker_egress.py
beardthelion b534f4b8c8 fix(security): match credential env names case-insensitively
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.
2026-09-20 12:53:47 -07:00

300 lines
14 KiB
Python

"""Egress-proxy (iron-proxy) plumbing for the Docker backend.
Builds the mount/env/host args that route a sandbox through the host-side
credential firewall, and guards the three config surfaces (docker_forward_env,
docker_env, docker_extra_args) that could otherwise weaken or bypass it.
"""
from __future__ import annotations
import hashlib
import json
import logging
logger = logging.getLogger("tools.environments.docker")
_EGRESS_LABEL_KEY = "hermes-egress"
_CONTAINER_CA = "/etc/ssl/certs/hermes-egress-ca.crt"
_NODE_OPTIONS_SENTINEL = "_HERMES_EGRESS_NODE_OPTIONS_APPEND"
_CA_MODE_FLAGS = {"--use-openssl-ca", "--use-bundled-ca"}
# Env names whose override would weaken or bypass enforced egress.
_PROXY_CONTROL_ENV = frozenset({
"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy",
"NO_PROXY", "no_proxy",
"REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS"})
def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str]]:
"""``(volume_args, env_overrides, host_args)`` for routing through iron-proxy; all empty
when the proxy is disabled/unconfigured/not running. Under ``proxy.enforce_on_docker``
(default) any half-configured state raises so the sandbox refuses to start unprotected;
otherwise it warns and continues. Only ImportError is swallowed — a broken config must
fail visibly rather than silently disable enforcement."""
try:
from hermes_cli.config import load_config
from agent.proxy_sources import iron_proxy as ip
except ImportError as exc:
logger.debug("Egress proxy plumbing unavailable: %s", exc)
return ([], {}, [])
proxy_cfg = load_config().get("proxy") or {}
if not proxy_cfg.get("enabled"):
return ([], {}, [])
status = ip.get_status()
enforce = bool(proxy_cfg.get("enforce_on_docker", True))
def _degraded(msg: str):
if enforce:
raise RuntimeError(msg)
logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg)
return ([], {}, [])
if not status.configured:
return _degraded(
"proxy.enabled is true but iron-proxy is not configured. "
"Run `hermes egress setup` to mint tokens and write proxy.yaml.")
if not (status.pid and status.listening):
return _degraded(
f"iron-proxy is enabled but not running on port {status.tunnel_port}. "
"Start it with `hermes egress start`.")
if status.ca_cert_path is None or not status.ca_cert_path.exists():
# Configured a moment ago but the trust anchor vanished: proxy env vars
# without the CA would make every TLS handshake fail.
return _degraded(
f"iron-proxy CA cert vanished from {status.ca_cert_path}. "
"Re-run `hermes egress setup` to regenerate it.")
# Empty/corrupt mappings look like an upstream outage from inside the
# sandbox (every request 403s); refuse rather than ship a broken sandbox.
mappings = ip.load_mappings()
if not mappings:
return _degraded(
"iron-proxy is configured but mappings.json is empty or "
"corrupt. Re-run `hermes egress setup` to mint provider "
"tokens before starting a sandbox.")
volume_args = ["-v", f"{status.ca_cert_path}:{_CONTAINER_CA}:ro"]
# tunnel_port serves CONNECT (HTTPS); the plain-HTTP forward listener is on +1.
proxy_url = f"http://host.docker.internal:{status.tunnel_port}"
plain_http_url = f"http://host.docker.internal:{status.tunnel_port + 1}"
env_overrides: dict[str, str] = {
# Both casings: some tools only read one.
"HTTPS_PROXY": proxy_url,
"https_proxy": proxy_url,
"HTTP_PROXY": plain_http_url,
"http_proxy": plain_http_url,
# Loopback-only so in-sandbox dev servers/local LLMs bypass the proxy.
"NO_PROXY": "127.0.0.1,localhost,::1",
"no_proxy": "127.0.0.1,localhost,::1",
# CA bundles: Python/curl vars REPLACE the system store, NODE_EXTRA_CA_CERTS
# only ADDS to it. NODE_OPTIONS=--use-openssl-ca narrows that asymmetry
# but must be APPENDED to the operator's NODE_OPTIONS, not clobber it —
# so it travels in a sentinel key that merge_egress_env() resolves.
"REQUESTS_CA_BUNDLE": _CONTAINER_CA,
"SSL_CERT_FILE": _CONTAINER_CA,
"CURL_CA_BUNDLE": _CONTAINER_CA,
"NODE_EXTRA_CA_CERTS": _CONTAINER_CA,
"HERMES_EGRESS_PROXY": "1", # lets the in-sandbox agent know it is proxy-aware
_NODE_OPTIONS_SENTINEL: "--use-openssl-ca"}
# Proxy tokens under the standard provider env names (and their aliases) so
# SDKs work unchanged; HERMES_PROXY_TOKEN_* copies are for diagnostics.
for m in mappings:
env_overrides[m.real_env_name] = m.proxy_token
env_overrides[f"HERMES_PROXY_TOKEN_{m.real_env_name}"] = m.proxy_token
for alias in getattr(m, "alias_env_names", ()) or ():
env_overrides[alias] = m.proxy_token
# Linux needs an explicit host-gateway mapping; Docker Desktop already has it.
host_args = ["--add-host", "host.docker.internal:host-gateway"]
return (volume_args, env_overrides, host_args)
def _egress_reuse_fingerprint(
volume_args: list[str], env_overrides: dict[str, str], host_args: list[str]) -> str:
"""Stable Docker-label value for the egress posture of a container."""
if not (volume_args or env_overrides or host_args):
return "off"
payload = json.dumps(
{"volume_args": volume_args, "env_overrides": env_overrides, "host_args": host_args},
sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
def _egress_enforce_on_docker(default: bool = True) -> bool:
"""Read proxy.enforce_on_docker; any config failure fails safe to *default*."""
try:
from hermes_cli.config import load_config
return bool((load_config().get("proxy") or {}).get("enforce_on_docker", default))
except (ImportError, OSError):
return default
except Exception as exc: # malformed config.yaml etc.
logger.warning("Could not read proxy config for egress collision check: %s", exc)
return default
def _critical_egress_env_names(env_overrides: dict[str, str]) -> set[str]:
"""Env names that would weaken or bypass enforced egress if overridden.
Every name the egress layer writes is critical: mapped tokens land under arbitrary
``real_env_name``/``alias_env_names`` entries from mappings.json, so a suffix
heuristic would silently unprotect any mapped credential not ending in
``_API_KEY``/``_TOKEN`` (e.g. ``AWS_SECRET_ACCESS_KEY``)."""
return set(_PROXY_CONTROL_ENV) | {"NODE_OPTIONS"} | set(env_overrides)
# ``docker run`` boolean shorthands that may precede a value-taking shorthand
# in a single-dash chain (pflag: "-iteNAME=v" parses as -i -t -e NAME=v).
_DOCKER_RUN_BOOL_SHORTHANDS = frozenset("ditPq")
def _env_flag_value(arg: str) -> tuple[str, str | None] | None:
"""``("env"|"env-file", inline_value)`` when ``arg`` spells a docker env flag
under pflag parsing, else ``None``. ``inline_value`` is the joined value
(``--env=N=v``, ``-eN=v``) or ``None`` when the flag consumes the next arg
(``-e``, ``--env``, ``-ite``)."""
if arg.startswith("--"):
flag, sep, inline = arg.partition("=")
if flag in ("--env", "--env-file"):
return flag[2:], inline if sep else None
return None
if not arg.startswith("-"):
return None # positional (image/command), never a flag
# A single dash heads a shorthand chain: every letter before the last must
# be a boolean shorthand, and the last takes the rest of the arg (or the
# next arg when bare) as its value.
for pos, ch in enumerate(arg[1:]):
if ch in _DOCKER_RUN_BOOL_SHORTHANDS:
continue
if ch == "e":
rest = arg[pos + 2:]
if rest.startswith("="):
rest = rest[1:]
return "env", rest or None
return None
return None
def _extra_args_egress_collisions(extra_args: list[str], critical_names: set[str]) -> list[str]:
"""Return docker_extra_args entries that can override egress controls."""
# Folded membership: ``-e NAME`` resolves NAME in the host env, which is
# case-insensitive on Windows — ``-eopenai_api_key`` would inject the real
# credential past the token swap.
critical_folded = {n.upper() for n in critical_names}
collisions: list[str] = []
i = 0
while i < len(extra_args):
arg = extra_args[i]
# pflag stops flag parsing at "--"; the rest is image/command. Bare
# positionals keep being scanned: a preceding long flag may consume
# them as its value, and over-scanning errs toward enforcement.
if arg == "--":
break
parsed = _env_flag_value(arg)
if parsed is not None:
kind, inline_value = parsed
if kind == "env-file":
collisions.append("--env-file")
else:
value = inline_value if inline_value is not None else (
extra_args[i + 1] if i + 1 < len(extra_args) else "")
name = value.split("=", 1)[0]
if name.upper() in critical_folded:
collisions.append(name)
i += 1 if inline_value is not None else 2
continue
if arg.partition("=")[0] in ("--network", "--net"):
collisions.append(arg)
i += 1
return sorted(set(collisions))
def _collision_guard(msg: str, *, enforce: bool, remedy: str, consequence: str) -> None:
"""Fail loud under enforcement, otherwise warn and let the user's config win."""
msg = f"{msg}; enforce_on_docker is {'enabled' if enforce else 'disabled'}."
if enforce:
raise RuntimeError(f"{msg} {remedy}")
logger.warning("%s %s", msg, consequence)
def check_forward_env_collisions(forward_env: list[str], critical: set[str], enforce: bool) -> None:
# Folded membership: forward_env names are resolved via os.getenv() on the
# host, which is case-insensitive on Windows — ``openai_api_key`` would
# inject the real credential into the container under a variant name.
critical_folded = {k.upper() for k in critical}
collisions = sorted(k for k in forward_env if k.upper() in critical_folded)
if collisions:
_collision_guard(
f"docker_forward_env would inject real egress-protected variables {collisions}",
enforce=enforce,
remedy="Remove these names from docker_forward_env or disable enforce_on_docker "
"to opt out of egress isolation.",
consequence="Explicit docker_forward_env values will override egress tokens.")
def check_docker_env_collisions(user_env: dict[str, str], egress_env: dict[str, str], enforce: bool) -> None:
"""Reject docker_env overrides of proxy-control vars or real provider keys (read from the
live token mappings; for those ANY override collides since a real key bypasses the swap)."""
provider_keys: set[str] = set()
try:
from agent.proxy_sources import iron_proxy as ip
provider_keys = {m.real_env_name for m in ip.load_mappings()}
except Exception: # best-effort
pass
def _collides(k: str) -> bool:
if k not in user_env:
return False
if k in provider_keys:
return k not in egress_env or user_env[k] != egress_env[k]
return k in egress_env and user_env[k] != egress_env[k]
collisions = sorted(k for k in (_PROXY_CONTROL_ENV | provider_keys) if _collides(k))
if collisions:
_collision_guard(
f"docker_env in config.yaml overrides egress-proxy variables {collisions}",
enforce=enforce,
remedy="Remove these keys from docker_env or disable enforce_on_docker to opt out "
"of egress isolation.",
consequence="Falling back to docker_env values; sandbox traffic will NOT route "
"through the proxy.")
def check_extra_args_collisions(extra_args: list[str], critical: set[str], enforce: bool) -> None:
collisions = _extra_args_egress_collisions(extra_args, critical)
if collisions:
_collision_guard(
f"docker_extra_args would override egress-proxy controls {collisions}",
enforce=enforce,
remedy="Remove these args or disable enforce_on_docker to opt out of egress isolation.",
consequence="Extra Docker args may bypass egress isolation.")
def merge_egress_env(user_env: dict[str, str], egress_env: dict[str, str], enforce: bool) -> dict[str, str]:
"""Merge docker_env with egress overrides (egress wins under enforcement, docker_env
otherwise) and resolve the NODE_OPTIONS sentinel: the flag is APPENDED to the operator's
NODE_OPTIONS after stripping conflicting CA-mode flags so it wins deterministically."""
merged = {**user_env, **egress_env} if enforce and egress_env else {**egress_env, **user_env}
raw_append = merged.pop(_NODE_OPTIONS_SENTINEL, None)
if raw_append:
append_token = raw_append.strip()
tokens = merged.get("NODE_OPTIONS", "").split()
if append_token in _CA_MODE_FLAGS:
dropped = [t for t in tokens if t in _CA_MODE_FLAGS and t != append_token]
if dropped:
logger.warning(
"Overriding conflicting NODE_OPTIONS CA-mode flag(s) %s "
"with egress-required %s to keep Node routed through the "
"egress CA store.", dropped, append_token)
tokens = [t for t in tokens if t not in _CA_MODE_FLAGS or t == append_token]
if append_token not in tokens:
tokens.append(append_token)
merged["NODE_OPTIONS"] = " ".join(tokens).strip()
if not merged["NODE_OPTIONS"]:
merged.pop("NODE_OPTIONS", None)
return merged