Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -3510,6 +3510,11 @@
# mounts are added, no binaries are auto-installed — feature is a
# complete no-op.
"enabled": False,
# Let named profiles reuse this profile's running daemon, CA, token
# mappings, and allowlist. Only the default/root profile's value is
# authoritative; named profiles keep their own proxy when enabled.
# Management commands remain scoped to the owning profile.
"share_with_profiles": False,
# Tunnel listener port. Sandboxes get `HTTPS_PROXY=http://<host>:<port>`.
# 9090 is the default; collide-aware setup wizard can reassign.
"tunnel_port": 9090,
Expand Down
123 changes: 123 additions & 0 deletions tests/test_iron_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,129 @@ def test_docker_egress_node_options_uses_sentinel(hermes_home, monkeypatch):
)


def _configure_running_proxy(home, *, token_value, share_with_profiles=False):
from hermes_cli.config import load_config, save_config
from hermes_constants import reset_hermes_home_override, set_hermes_home_override

scope = set_hermes_home_override(home)
try:
cfg = load_config()
cfg["proxy"].update(
enabled=True,
enforce_on_docker=True,
share_with_profiles=share_with_profiles,
)
save_config(cfg)

state = ip._proxy_state_dir()
ca = state / "ca.crt"
ca.write_text("fake-ca", encoding="utf-8")
(state / "ca.key").write_text("fake-key", encoding="utf-8")
mapping = ip.TokenMapping(
proxy_token=token_value,
real_env_name="OPENROUTER_API_KEY",
upstream_hosts=("openrouter.ai",),
)
ip.write_proxy_config(
ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca,
ca_key=state / "ca.key",
tunnel_port=9090,
)
)
ip.write_mappings([mapping])
(state / "iron-proxy.pid").write_text("99999", encoding="utf-8")
return ca
finally:
reset_hermes_home_override(scope)


def test_docker_egress_reuses_opted_in_default_proxy(tmp_path, monkeypatch):
from hermes_constants import get_hermes_home
from tools.environments.docker import _egress_proxy_args_for_docker

root = tmp_path / "hermes"
profile = root / "profiles" / "bot"
profile.mkdir(parents=True)
default_ca = _configure_running_proxy(
root,
token_value="shared-token",
share_with_profiles=True,
)
monkeypatch.setenv("HERMES_HOME", str(profile))
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "_port_listening", lambda host, port: True)

volumes, env, hosts = _egress_proxy_args_for_docker()

assert volumes == [
"-v",
f"{default_ca}:/etc/ssl/certs/hermes-egress-ca.crt:ro",
]
assert env["OPENROUTER_API_KEY"] == "shared-token"
assert hosts == ["--add-host", "host.docker.internal:host-gateway"]
assert get_hermes_home() == profile


def test_docker_egress_keeps_default_proxy_isolated_without_opt_in(
tmp_path, monkeypatch,
):
from tools.environments.docker import _egress_proxy_args_for_docker

root = tmp_path / "hermes"
profile = root / "profiles" / "bot"
profile.mkdir(parents=True)
_configure_running_proxy(root, token_value="private-token")
monkeypatch.setenv("HERMES_HOME", str(profile))
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "_port_listening", lambda host, port: True)

assert _egress_proxy_args_for_docker() == ([], {}, [])


def test_docker_egress_prefers_profile_local_proxy(tmp_path, monkeypatch):
from tools.environments.docker import _egress_proxy_args_for_docker

root = tmp_path / "hermes"
profile = root / "profiles" / "bot"
profile.mkdir(parents=True)
_configure_running_proxy(
root,
token_value="shared-token",
share_with_profiles=True,
)
profile_ca = _configure_running_proxy(profile, token_value="local-token")
monkeypatch.setenv("HERMES_HOME", str(profile))
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "_port_listening", lambda host, port: True)

volumes, env, _ = _egress_proxy_args_for_docker()

assert volumes[1].startswith(str(profile_ca))
assert env["OPENROUTER_API_KEY"] == "local-token"


def test_shared_proxy_uses_default_owner_enforcement(tmp_path, monkeypatch):
from hermes_cli.config import load_config, save_config
from tools.environments.docker import _egress_enforce_on_docker

root = tmp_path / "hermes"
profile = root / "profiles" / "bot"
profile.mkdir(parents=True)
_configure_running_proxy(
root,
token_value="shared-token",
share_with_profiles=True,
)
monkeypatch.setenv("HERMES_HOME", str(profile))
cfg = load_config()
cfg["proxy"]["enforce_on_docker"] = False
save_config(cfg)

assert _egress_enforce_on_docker() is True


# ---------------------------------------------------------------------------
# v3: ensure_audit_log fails loud on OSError (P2 promise mismatch)
# ---------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions tests/tools/test_docker_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,27 @@ def test_extra_args_proxy_override_refuses_under_egress(monkeypatch):
_make_dummy_env(extra_args=["-e", "HTTPS_PROXY="])


def test_enforced_egress_rejects_docker_env_provider_key(monkeypatch):
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env,
"_egress_proxy_args_for_docker",
lambda: (
[],
{
"OPENROUTER_API_KEY": "shared-proxy-token",
"HERMES_PROXY_TOKEN_OPENROUTER_API_KEY": "shared-proxy-token",
},
[],
),
)
monkeypatch.setattr(docker_env, "_egress_enforce_on_docker", lambda: True)
_mock_subprocess_run(monkeypatch)

with pytest.raises(RuntimeError, match="docker_env.*OPENROUTER_API_KEY"):
_make_dummy_env(env={"OPENROUTER_API_KEY": "real-provider-key"})


def test_reuse_starts_stopped_container_before_attaching(monkeypatch):
"""A labeled container in ``exited`` state must be restarted via
``docker start`` before the new Hermes process uses it. Without this
Expand Down
133 changes: 77 additions & 56 deletions tools/environments/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,38 @@ def _extra_args_set_shm_size(extra_args: list) -> bool:
]


def _resolve_egress_proxy_owner() -> tuple[dict, Path | None] | None:
"""Return the effective proxy config and optional shared-owner home."""
from hermes_cli.config import load_config

proxy_cfg = load_config().get("proxy") or {}
if proxy_cfg.get("enabled"):
return proxy_cfg, None

from hermes_constants import (
get_default_hermes_root,
get_hermes_home,
reset_hermes_home_override,
set_hermes_home_override,
)

default_home = get_default_hermes_root()
if get_hermes_home().resolve() == default_home.resolve():
return None

token = set_hermes_home_override(default_home)
try:
default_proxy_cfg = load_config().get("proxy") or {}
finally:
reset_hermes_home_override(token)
if not (
default_proxy_cfg.get("enabled")
and default_proxy_cfg.get("share_with_profiles")
):
return None
return default_proxy_cfg, default_home


def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str]]:
"""Build the docker mount/env/host args needed to route a sandbox through
the iron-proxy egress firewall.
Expand Down Expand Up @@ -427,12 +459,25 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str
logger.debug("Egress proxy plumbing unavailable: %s", exc)
return ([], {}, [])

cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
if not proxy_cfg.get("enabled"):
resolved = _resolve_egress_proxy_owner()
if resolved is None:
return ([], {}, [])
proxy_cfg, shared_owner_home = resolved
shared_from_default = shared_owner_home is not None
owner_token = None
if shared_owner_home is not None:
from hermes_constants import set_hermes_home_override

owner_token = set_hermes_home_override(shared_owner_home)
try:
status = ip.get_status()
mappings = ip.load_mappings()
finally:
if owner_token is not None:
from hermes_constants import reset_hermes_home_override

reset_hermes_home_override(owner_token)

status = ip.get_status()
enforce = bool(proxy_cfg.get("enforce_on_docker", True))

if not status.configured:
Expand All @@ -446,9 +491,14 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str
return ([], {}, [])

if not (status.pid and status.listening):
start_hint = (
"Start it from the default profile with `hermes egress start`."
if shared_from_default
else "Start it with `hermes egress start`."
)
msg = (
f"iron-proxy is enabled but not running on port {status.tunnel_port}. "
"Start it with `hermes egress start`."
f"{start_hint}"
)
if enforce:
raise RuntimeError(msg)
Expand Down Expand Up @@ -476,7 +526,6 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str
# indistinguishable from an upstream outage from inside the sandbox
# (every request returns 403). Refuse to mount with empty mappings
# rather than ship a broken sandbox.
mappings = ip.load_mappings()
if not mappings:
msg = (
"iron-proxy is configured but mappings.json is empty or "
Expand Down Expand Up @@ -576,11 +625,13 @@ def _egress_reuse_fingerprint(


def _egress_enforce_on_docker(default: bool = True) -> bool:
"""Read proxy.enforce_on_docker with fail-safe defaulting."""
"""Read the effective proxy owner's enforcement policy fail-safely."""
try:
from hermes_cli.config import load_config as _load_cfg

return bool((_load_cfg().get("proxy") or {}).get("enforce_on_docker", default))
resolved = _resolve_egress_proxy_owner()
if resolved is None:
return default
proxy_cfg, _ = resolved
return bool(proxy_cfg.get("enforce_on_docker", default))
except (ImportError, OSError):
return default
except Exception:
Expand Down Expand Up @@ -1138,46 +1189,28 @@ def __init__(
# behavior) but we log a warning naming both config sources.
# - When the user override is identical to the egress value, no-op.
if egress_env_overrides:
try:
from hermes_cli.config import load_config as _load_cfg_for_collision
_proxy_cfg = (_load_cfg_for_collision().get("proxy") or {})
except (ImportError, OSError):
_proxy_cfg = {}
except Exception as _e: # noqa: BLE001 — narrowed below via yaml import
# yaml.YAMLError from a malformed config.yaml. We import
# lazily because PyYAML is a soft dep in some test envs.
try:
import yaml # noqa: F401
except ImportError:
raise
logger.warning(
"Could not read proxy config for egress collision check: %s",
_e,
)
_proxy_cfg = {}
_enforce_egress = bool(_proxy_cfg.get("enforce_on_docker", True))
# Egress-controlling env vars that affect the proxy posture.
_critical_proxy_control = {
"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",
}
# stephenschoettler #2: also block docker_env from injecting
# real provider keys. `docker_env: {OPENROUTER_API_KEY: sk-real}`
# in config.yaml puts the live secret into the sandbox while
# egress is nominally enforced — defeats the entire feature.
# Pull the mapped real_env_name from each token mapping at
# call time so this stays in sync with whatever the operator
# has configured.
_critical_provider_keys: set[str] = set()
try:
from agent.proxy_sources import iron_proxy as _ip_for_mappings
_critical_provider_keys = {
m.real_env_name for m in _ip_for_mappings.load_mappings()
}
except Exception: # noqa: BLE001 — best-effort collision check
pass
# Also block docker_env from injecting real provider keys.
# Derive these from the already-resolved egress environment so a
# shared proxy keeps using its owner's mappings after the temporary
# HERMES_HOME scope has been reset.
_proxy_token_values = {
value
for key, value in egress_env_overrides.items()
if key.startswith("HERMES_PROXY_TOKEN_")
}
_critical_provider_keys = {
key
for key, value in egress_env_overrides.items()
if not key.startswith("HERMES_PROXY_TOKEN_")
and value in _proxy_token_values
}
_critical = _critical_proxy_control | _critical_provider_keys
_collisions = sorted(
k for k in _critical
Expand Down Expand Up @@ -1217,19 +1250,7 @@ def __init__(
# false, docker_env wins (back-compat for users who deliberately
# opt out). In both cases the collision check above has already
# surfaced any disagreement.
try:
from hermes_cli.config import load_config as _load_cfg_for_precedence
_enforce_egress_merge = bool(
(_load_cfg_for_precedence().get("proxy") or {})
.get("enforce_on_docker", True)
)
except (ImportError, OSError):
_enforce_egress_merge = True
except Exception: # noqa: BLE001 — yaml.YAMLError or similar
# Malformed config.yaml; fail-safe to enforced.
_enforce_egress_merge = True

if _enforce_egress_merge and egress_env_overrides:
if _enforce_egress and egress_env_overrides:
merged_env = dict(self._env)
merged_env.update(egress_env_overrides)
else:
Expand Down
6 changes: 5 additions & 1 deletion website/docs/developer-guide/egress-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,11 @@ _NON_BEARER_PROVIDERS: Tuple[str, ...] = (

`_egress_proxy_args_for_docker` is Docker-specific. Backends that want similar wiring need their own analogue that:

1. Reads `load_config().get("proxy", {})`; returns empty args if `enabled` is false.
1. Reads `load_config().get("proxy", {})`. If the active named profile has no
local proxy, resolve `get_default_hermes_root()` under a context-local
`HERMES_HOME` override and use that root only when both `enabled` and
`share_with_profiles` are true. Never mutate the process environment, and
always reset the context override. A profile-local enabled proxy wins.
2. Calls `iron_proxy.get_status()`; surfaces `enforce` semantics on `configured` / `pid` / `listening` / `ca_cert_path` failure paths.
3. Calls `iron_proxy.load_mappings()`; refuses to mount if empty AND `enforce_on_docker: true`.
4. Sets the seven env vars (HTTPS_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, HERMES_EGRESS_PROXY) and the per-mapping `HERMES_PROXY_TOKEN_<NAME>` vars.
Expand Down
Loading
Loading