249 lines
11 KiB
Python
249 lines
11 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."""
|
|
critical = set(_PROXY_CONTROL_ENV) | {"NODE_OPTIONS"}
|
|
critical.update(k for k in env_overrides if k.endswith("_API_KEY") or k.endswith("_TOKEN"))
|
|
return critical
|
|
|
|
|
|
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."""
|
|
collisions: list[str] = []
|
|
i = 0
|
|
while i < len(extra_args):
|
|
arg = extra_args[i]
|
|
flag, sep, inline_value = arg.partition("=") # ``-e NAME=v`` vs ``-e=NAME=v`` / ``--env-file=f``
|
|
if flag in ("-e", "--env", "--env-file"):
|
|
value = inline_value if sep else (extra_args[i + 1] if i + 1 < len(extra_args) else "")
|
|
name = value.split("=", 1)[0]
|
|
if flag == "--env-file":
|
|
collisions.append(flag)
|
|
elif name in critical_names:
|
|
collisions.append(name)
|
|
i += 1 if sep else 2
|
|
continue
|
|
if flag 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:
|
|
collisions = sorted(k for k in forward_env if k in critical)
|
|
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
|