Merge pull request #58489 from NousResearch/revert-30179

Revert "feat(egress): iron-proxy credential-injection firewall" (#30179)
This commit is contained in:
Teknium 2026-07-04 13:41:24 -07:00 committed by GitHub
commit e670d9cdd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 29 additions and 8363 deletions

View file

@ -1,8 +0,0 @@
"""Egress proxy integrations.
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
outbound traffic from remote terminal sandboxes and swaps proxy tokens
for real upstream credentials at the network edge.
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
"""

File diff suppressed because it is too large Load diff

View file

@ -240,7 +240,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
labelKey: 'providerApiKeys',
tab: 'providers&pview=keys'
},
@ -253,7 +253,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
labelKey: 'keysSettings',
tab: 'keys&kview=settings'
},

4
cli.py
View file

@ -8584,10 +8584,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._show_gateway_status()
elif canonical == "status":
self._show_session_status()
elif canonical == "egress":
from hermes_cli.proxy_cli import format_status_text
self._console_print(format_status_text(), highlight=False, markup=False)
elif canonical == "statusbar":
self._status_bar_visible = not self._status_bar_visible
state = "visible" if self._status_bar_visible else "hidden"

View file

@ -8952,11 +8952,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _cmd_def_inner and _cmd_def_inner.name == "restart":
return await self._handle_restart_command(event)
if _cmd_def_inner and _cmd_def_inner.name == "egress":
from hermes_cli.proxy_cli import format_status_text
return format_status_text()
# /stop must hard-kill the session when an agent is running.
# A soft interrupt (agent.interrupt()) doesn't help when the agent
# is truly hung — the executor thread is blocked and never checks
@ -9435,11 +9430,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "status":
return await self._handle_status_command(event)
if canonical == "egress":
from hermes_cli.proxy_cli import format_status_text
return format_status_text()
if canonical == "agents":
return await self._handle_agents_command(event)

View file

@ -118,8 +118,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
CommandDef("status", "Show session, model, token, and context info", "Session"),
CommandDef("egress", "Show Docker egress proxy status", "Session",
args_hint="[status]", subcommands=("status",)),
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
CommandDef("profile", "Show active profile name and home directory", "Info"),
CommandDef("sethome", "Set this chat as the home channel", "Session",
@ -559,7 +557,6 @@ _TELEGRAM_MENU_PRIORITY = (
"new",
"stop",
"status",
"egress",
"resume",
"sessions",
"model",
@ -1166,8 +1163,7 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# - moa: high-cost slash mode, available through /hermes moa to avoid
# displacing existing native Slack slash commands at the 50-command cap.
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
# - egress: Docker-only proxy status; reachable as /hermes egress on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug", "egress"})
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"})
def _sanitize_slack_name(raw: str) -> str:

View file

@ -3090,66 +3090,6 @@ DEFAULT_CONFIG = {
"cua_telemetry": False,
},
# =========================================================================
# Egress credential-injection proxy (iron-proxy)
# =========================================================================
# When enabled, outbound traffic from remote terminal sandboxes (Docker
# today; Modal/SSH in follow-ups) is routed through a managed iron-proxy
# subprocess. The sandbox sees opaque proxy tokens; iron-proxy swaps in
# real API credentials at the egress boundary. Compromising the sandbox
# leaks tokens that only work behind the configured trusted proxy boundary
# (CA private key + proxy endpoint integrity are part of that boundary).
#
# Configure with `hermes egress setup`. Disabled by default — the rest of
# Hermes works exactly as before with `enabled: false`.
"proxy": {
# Master switch. When false, iron-proxy is never started, no docker
# mounts are added, no binaries are auto-installed — feature is a
# complete no-op.
"enabled": False,
# Tunnel listener port. Sandboxes get `HTTPS_PROXY=http://<host>:<port>`.
# 9090 is the default; collide-aware setup wizard can reassign.
"tunnel_port": 9090,
# Auto-download the pinned iron-proxy binary into ~/.hermes/bin/ on
# first use. When false, you must place `iron-proxy` on PATH yourself.
"auto_install": True,
# Where iron-proxy looks up the real upstream secrets at egress time.
# "env" — process env (default; what bitwarden integration
# already populates if you use it)
# "bitwarden" — refetch via `bws secret list` on each proxy restart;
# rotation in the Bitwarden web app propagates without
# touching .env (requires `secrets.bitwarden.enabled`).
"credential_source": "env",
# When true, the Docker backend refuses to start a sandbox if the
# proxy is enabled but not running. False = fall back to direct
# outbound with real credentials in the sandbox (the legacy posture).
"enforce_on_docker": True,
# NOTE: ``fail_on_uncovered_providers`` was removed. It gated a
# refuse-start when Anthropic / Azure OpenAI / Gemini env vars were
# present — those providers are now first-class swapped providers
# via per-provider match_headers rules (x-api-key, api-key,
# x-goog-api-key), so the fail-closed tier is empty. A leftover
# key in existing user configs is ignored harmlessly.
# When credential_source is bitwarden but the BWS access token /
# project_id is missing OR the bws fetch returns no values for
# mapped providers, the daemon raises by default. Set this to
# True to opt back in to the legacy "silently fall back to host
# env" behaviour — useful for migrations where the operator wants
# to switch credential_source to bitwarden but hasn't fully wired
# BWS yet. Defaults to false (strict).
"allow_env_fallback": False,
# SSRF deny list applied to outbound traffic. Omit / leave empty
# to use the safe default: loopback, link-local (incl. cloud
# metadata IPs at 169.254.169.254), and RFC1918. Set to an
# explicit ``[]`` to opt out entirely (only sensible in hermetic
# tests that need to reach a loopback upstream).
"upstream_deny_cidrs": None,
# Extra allowed upstream hosts beyond the bundled defaults (which
# cover OpenRouter, OpenAI, Anthropic, Google, xAI, Mistral, Groq,
# Together, DeepSeek, Nous). Wildcards (`*.foo.com`) are supported.
"extra_allowed_hosts": [],
},
# Hermes Desktop (Electron app) launch options. These only affect
# `hermes desktop`; they do not touch the CLI/gateway.
"desktop": {

View file

@ -12188,7 +12188,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
"computer-use",
"config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor",
"dump", "egress", "fallback", "gateway", "hooks", "import", "insights",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
"journey", "memory-graph", "learning",
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile",
@ -12803,37 +12803,6 @@ def main():
secrets_parser.set_defaults(func=_dispatch_secrets)
# =========================================================================
# egress command — iron-proxy outbound credential-injection firewall
# =========================================================================
# NOTE: this is the OUTBOUND egress firewall (ironsh/iron-proxy).
# `hermes proxy` (defined elsewhere in this file) is a separate INBOUND
# OAuth-aggregator reverse proxy. Different direction, different purpose.
egress_parser = subparsers.add_parser(
"egress",
help="Manage the iron-proxy egress credential-injection firewall",
description=(
"Manage iron-proxy, the optional TLS-intercepting egress firewall "
"that swaps proxy tokens for real API credentials before outbound "
"requests leave a sandbox. Disabled by default. See: "
"https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy"
),
)
from hermes_cli import proxy_cli as _proxy_cli
_proxy_cli.register_cli(egress_parser)
def _dispatch_egress(args): # noqa: ANN001
# The egress subparser uses dest='egress_command' to stay disjoint
# from the inbound OAuth ``hermes proxy`` subparser (dest='proxy_command').
sub = getattr(args, "egress_command", None)
if sub is not None and hasattr(args, "func") and args.func is not _dispatch_egress:
return args.func(args)
egress_parser.print_help()
return 0
egress_parser.set_defaults(func=_dispatch_egress)
# =========================================================================
# migrate command
# =========================================================================
@ -13944,15 +13913,9 @@ def main():
cmd_chat(args)
return
# Execute the command. Propagate the handler's return code as the
# process exit code so subcommands that signal failure (e.g.
# ``hermes egress start`` refusing when credential_source=bitwarden
# is misconfigured) actually exit non-zero. Handlers that return
# None are treated as success (exit 0).
# Execute the command
if hasattr(args, "func"):
rc = args.func(args)
if isinstance(rc, int) and rc != 0:
sys.exit(rc)
args.func(args)
else:
parser.print_help()

View file

@ -1,904 +0,0 @@
"""CLI handlers for ``hermes egress ...``.
Subcommands:
install download the pinned iron-proxy binary
setup interactive wizard: install binary, generate CA, mint tokens, write config
start launch the proxy as a managed subprocess
stop terminate the managed proxy
status show binary version + config presence + listen state + mappings
disable flip ``proxy.enabled`` to False (does not stop a running proxy)
config print the generated proxy.yaml path (for debugging / external review)
The top-level command is ``hermes egress``. Note that the inbound OAuth
reverse-proxy command (``hermes proxy``) lives elsewhere in
``hermes_cli/main.py`` different direction, different purpose.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from typing import List
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from agent.proxy_sources import iron_proxy as ip
from hermes_cli.config import load_config, save_config
# ---------------------------------------------------------------------------
# Argparse wiring — called from hermes_cli.main
# ---------------------------------------------------------------------------
def register_cli(parent_parser: argparse.ArgumentParser) -> None:
"""Attach the egress subcommand tree to a parent parser.
Called from ``hermes_cli.main`` as part of building the top-level
``hermes egress`` parser.
"""
# dest='egress_command' — keeps this subparser tree disjoint from the
# inbound OAuth ``hermes proxy`` subparser (which uses dest='proxy_command').
# No runtime collision today since they live in separate parser trees,
# but a future grep-and-refactor on ``proxy_command`` would otherwise
# hit both handlers.
sub = parent_parser.add_subparsers(dest="egress_command")
install = sub.add_parser(
"install",
help=f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})",
)
install.add_argument(
"--force", action="store_true",
help="Re-download even if a managed copy already exists",
)
install.set_defaults(func=cmd_install)
setup = sub.add_parser(
"setup",
help="Interactive wizard: install + CA + mint tokens + write config",
)
setup.add_argument(
"--tunnel-port", type=int, default=None,
help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})",
)
setup.add_argument(
"--from-bitwarden", action="store_true",
help="Treat secrets as managed by Bitwarden — discover provider keys "
"from secrets.bitwarden config instead of the current env. Fails "
"loudly if BW is unreachable rather than silently falling back.",
)
setup.add_argument(
"--no-bitwarden", action="store_true",
help="Explicitly switch credential_source back to env on re-setup "
"(only meaningful when the previous setup used --from-bitwarden).",
)
setup.add_argument(
"--rotate-tokens", action="store_true",
help="Mint fresh proxy tokens for every provider (default is to "
"preserve tokens for providers that already had one — avoids "
"401-ing already-running sandboxes on re-setup).",
)
setup.add_argument(
"--restart", dest="restart", action="store_true", default=None,
help="If a daemon is already running, restart it automatically after "
"writing the new config/tokens (non-interactive default on a tty "
"is to ask).",
)
setup.add_argument(
"--no-restart", dest="restart", action="store_false",
help="Do not restart a running daemon after setup; you'll need to run "
"`hermes egress restart` yourself for changes to take effect.",
)
setup.set_defaults(func=cmd_setup)
start = sub.add_parser("start", help="Start the managed iron-proxy")
start.set_defaults(func=cmd_start)
stop = sub.add_parser("stop", help="Stop the managed iron-proxy")
stop.set_defaults(func=cmd_stop)
restart = sub.add_parser(
"restart",
help="Restart the managed iron-proxy (stop if running, then start)",
)
restart.set_defaults(func=cmd_restart)
reload_p = sub.add_parser(
"reload",
help="Hot-reload the running daemon's ruleset from proxy.yaml "
"(management API — no restart, no dropped connections)",
)
reload_p.set_defaults(func=cmd_reload)
status = sub.add_parser("status", help="Show proxy state and mappings")
status.add_argument(
"--show-tokens", action="store_true",
help="Print the proxy tokens (default: redacted prefix only). "
"Beware: tokens may persist in your shell history.",
)
status.set_defaults(func=cmd_status)
disable = sub.add_parser("disable", help="Turn off the proxy integration")
disable.set_defaults(func=cmd_disable)
cfg = sub.add_parser("config", help="Print the generated proxy.yaml path")
cfg.set_defaults(func=cmd_config)
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def cmd_install(args: argparse.Namespace) -> int:
console = Console()
try:
binary = ip.install_iron_proxy(force=bool(args.force))
except Exception as exc: # noqa: BLE001 — top-level user-facing error funnel
console.print(f"[red]✗ install failed:[/red] {exc}")
console.print(
" Manual install: https://github.com/ironsh/iron-proxy/releases"
)
return 1
version = ip.iron_proxy_version(binary) or "(version unknown)"
console.print(f"[green]✓[/green] installed {binary} {version}")
return 0
def cmd_setup(args: argparse.Namespace) -> int:
console = Console()
console.print(Panel.fit(
"[bold]iron-proxy setup[/bold]\n\n"
"Routes outbound sandbox traffic through a local TLS-intercepting\n"
"proxy so prompt-injected agents never see real provider API keys.\n\n"
"[dim]Project: https://github.com/ironsh/iron-proxy (Apache-2.0)[/dim]",
border_style="cyan",
))
# ------------------------------------------------------------------ binary
console.print()
console.print("[bold]Step 1[/bold] Install the iron-proxy binary")
try:
binary = ip.find_iron_proxy(install_if_missing=False)
if binary is None:
console.print(" No iron-proxy on PATH — downloading…")
binary = ip.install_iron_proxy()
version = ip.iron_proxy_version(binary) or "(version unknown)"
console.print(f" [green]✓[/green] {binary} {version}")
except Exception as exc: # noqa: BLE001
console.print(f" [red]✗ install failed: {exc}[/red]")
return 1
# ------------------------------------------------------------------ CA
console.print()
console.print("[bold]Step 2[/bold] Generate a CA cert")
try:
ca_crt, ca_key = ip.ensure_ca_cert()
except Exception as exc: # noqa: BLE001
console.print(f" [red]✗ CA generation failed: {exc}[/red]")
return 1
console.print(f" [green]✓[/green] {ca_crt}")
# ------------------------------------------------------------------ mint
console.print()
console.print("[bold]Step 3[/bold] Mint proxy tokens for known providers")
available_env_names: List[str] = []
if args.from_bitwarden:
cfg = load_config()
bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {}
if not bw_cfg.get("enabled"):
console.print(
" [red]✗ --from-bitwarden requested but "
"secrets.bitwarden.enabled is false.[/red]"
)
console.print(
" Run `hermes secrets bitwarden setup` first, or omit "
"--from-bitwarden."
)
return 1
try:
from agent.secret_sources import bitwarden as bw
access_token = os.environ.get(
bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), ""
).strip()
if not access_token:
console.print(
f" [red]✗ --from-bitwarden requested but "
f"{bw_cfg.get('access_token_env', 'BWS_ACCESS_TOKEN')} "
"is not set in the environment.[/red]"
)
return 1
secrets, _ = bw.fetch_bitwarden_secrets(
access_token=access_token,
project_id=bw_cfg.get("project_id", ""),
cache_ttl_seconds=0,
use_cache=False,
)
available_env_names = list(secrets.keys())
if not available_env_names:
console.print(
" [red]✗ Bitwarden returned an empty secrets list.[/red]\n"
" Check the project_id in secrets.bitwarden and the "
"BWS access-token's project scope."
)
return 1
console.print(
f" Pulled {len(available_env_names)} env names from Bitwarden."
)
except Exception as exc: # noqa: BLE001 — explicit user-facing error
console.print(
f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]"
)
console.print(
" Either fix the Bitwarden config and retry, or rerun setup "
"without --from-bitwarden (the proxy will read secrets from "
"the host process env at start time)."
)
return 1
else:
# Env-based discovery reads os.environ. Operators commonly keep their
# provider keys only in ~/.hermes/.env (loaded automatically when the
# agent runs, but NOT exported into an interactive shell). Fall back
# to loading that file so `hermes egress setup` finds the same keys the
# agent would — otherwise a user with keys solely in .env sees a
# confusing "no provider keys found" when the keys clearly "exist".
loaded = _load_env_file_into_environ()
if loaded:
console.print(
f" [dim]Loaded {loaded} provider key name(s) from "
f"~/.hermes/.env for discovery.[/dim]"
)
discovered = ip.discover_provider_mappings(
available_env_names=available_env_names or None,
)
# Preserve tokens for providers we already had unless the operator
# explicitly requested rotation. This prevents re-running `hermes
# egress setup` from invalidating tokens baked into already-running
# sandboxes.
existing = ip.load_mappings()
rotate = bool(getattr(args, "rotate_tokens", False))
# P3 confirmation gate: --rotate-tokens invalidates every running
# sandbox's proxy tokens immediately. An accidental re-run (history
# scroll-back, tmux paste) is unrecoverable, so require explicit
# confirmation when there's something to actually rotate. Skipped
# when stdin isn't a tty (CI / non-interactive use), in which case
# the operator passed the flag deliberately.
if rotate and existing:
import sys as _sys
from datetime import datetime as _dt
if _sys.stdin.isatty():
console.print(
"[yellow]⚠[/yellow] --rotate-tokens will invalidate proxy "
"tokens in every running Hermes sandbox. They will start "
"401-ing against upstreams until restarted."
)
try:
ans = input("Type 'rotate' to confirm: ").strip().lower()
except EOFError:
ans = ""
if ans != "rotate":
console.print("[yellow]Cancelled.[/yellow]")
return 1
# Backup the existing mappings before we overwrite. The
# resulting ``.rotated-<unix>`` sibling is plain JSON and lets
# the operator manually recover tokens if they realise the
# rotation was a mistake.
try:
import shutil as _shutil
state_dir = ip._proxy_state_dir()
mappings_src = state_dir / "mappings.json"
if mappings_src.exists():
ts = _dt.now().strftime("%Y%m%dT%H%M%S")
backup = state_dir / f"mappings.json.rotated-{ts}"
_shutil.copy2(str(mappings_src), str(backup))
console.print(f" [dim]backup: {backup}[/dim]")
except OSError as exc:
console.print(
f" [yellow]Could not back up mappings before rotation: "
f"{exc}[/yellow]"
)
elif rotate and not existing:
console.print(
"[dim]Note: --rotate-tokens is a no-op on first-time setup "
"(no existing tokens to rotate).[/dim]"
)
mappings = ip.merge_mappings(
existing=existing,
discovered=discovered,
rotate=rotate,
)
if not mappings:
console.print(
" [yellow]No known provider API keys found in env/Bitwarden.[/yellow]"
)
console.print(
" Set at least one of these and rerun setup:"
)
for env_name in sorted(ip._BEARER_PROVIDERS):
console.print(f" - {env_name}")
return 1
# Warn the operator about providers we recognize but can't proxy
# (AWS Bedrock SigV4, GCP Vertex service-account OAuth). These still
# work — they just bypass the egress isolation.
uncovered = ip.discover_uncovered_providers(
available_env_names=available_env_names or None,
)
if uncovered:
console.print()
console.print(
" [yellow]⚠[/yellow] Detected provider env vars that the "
"proxy does not yet cover:"
)
for name in uncovered:
console.print(f" - {name}")
console.print(
" [dim]These providers use request signing or SDK-minted "
"OAuth (SigV4, service-account files) and will hold real "
"credentials inside the sandbox. Egress isolation is "
"INCOMPLETE for these.[/dim]"
)
table = Table(show_header=True, header_style="bold")
table.add_column("Provider env", style="cyan")
table.add_column("Upstream hosts", style="dim")
table.add_column("Proxy token", style="green")
for m in mappings:
table.add_row(
m.real_env_name,
", ".join(m.upstream_hosts),
_redact_token(m.proxy_token),
)
console.print(table)
# ------------------------------------------------------------------ write
console.print()
console.print("[bold]Step 4[/bold] Write config and persist mappings")
cfg = load_config()
proxy_cfg = cfg.setdefault("proxy", {})
# ``args.tunnel_port`` is None when the flag was not given; ``0`` is
# invalid for a TCP listener so we treat it as an explicit refusal
# and surface a clear error rather than silently substituting the
# default.
if args.tunnel_port is not None:
if args.tunnel_port < 1 or args.tunnel_port > 65534:
console.print(
" [red]✗ --tunnel-port must be between 1 and 65534 "
"(the plain-HTTP listener uses port+1).[/red]"
)
return 1
tunnel_port = int(args.tunnel_port)
else:
tunnel_port = int(proxy_cfg.get("tunnel_port", ip._DEFAULT_TUNNEL_PORT))
proxy_cfg["tunnel_port"] = tunnel_port
extra_hosts = list(proxy_cfg.get("extra_allowed_hosts") or [])
allowed = list(ip._DEFAULT_ALLOWED_HOSTS) + [
h for h in extra_hosts if h not in ip._DEFAULT_ALLOWED_HOSTS
]
audit_log_path = ip._proxy_state_dir() / "audit.log"
# Pre-create the audit log with 0o600. On the pinned v0.39 the
# daemon does NOT write to this file (no ``log.audit_path`` field in
# its config schema) — it's reserved for the v0.40+ upgrade where
# per-request records start flowing. Because the file is
# non-load-bearing today, a pre-create failure (immutable parent,
# pre-existing foreign-owned file, full disk) is a WARNING, not a
# setup abort.
audit_log_ok = True
try:
ip.ensure_audit_log(audit_log_path)
except RuntimeError as exc:
audit_log_ok = False
console.print(f" [yellow]⚠ {exc}[/yellow]")
# Allow operator override of the deny list via
# ``proxy.upstream_deny_cidrs`` — but the default (None) gives a safe
# default-deny list (loopback, IMDS, RFC1918) that matches the docs
# promise.
deny_cidrs = proxy_cfg.get("upstream_deny_cidrs")
iron_cfg = ip.build_proxy_config(
mappings=mappings,
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
audit_log=audit_log_path,
allowed_hosts=allowed,
upstream_deny_cidrs=deny_cidrs,
)
cfg_path = ip.write_proxy_config(iron_cfg)
mappings_path = ip.write_mappings(mappings)
# Mint (or keep) the management-API bearer key. The generated config
# enables a loopback management listener whose /v1/reload lets
# `hermes egress reload` apply future ruleset changes without a
# restart; the daemon requires the key env var to be non-empty at
# startup, so make sure the token exists before first start.
ip.ensure_management_token()
console.print(f" [green]✓[/green] config: {cfg_path}")
console.print(f" [green]✓[/green] mappings: {mappings_path}")
if audit_log_ok:
console.print(
f" [green]✓[/green] audit log: {audit_log_path} "
f"[dim](reserved — not written by iron-proxy v0.39; "
f"per-request records land in iron-proxy.log)[/dim]"
)
# ------------------------------------------------------------------ enable
proxy_cfg["enabled"] = True
proxy_cfg.setdefault("auto_install", True)
proxy_cfg.setdefault("enforce_on_docker", True)
# CRITICAL: do NOT silently downgrade credential_source on re-run.
# If the operator previously configured `bitwarden` mode (e.g. for
# rotation), running `hermes egress setup` again WITHOUT
# --from-bitwarden must not rewrite credential_source to "env" —
# that silently breaks the Bitwarden rotation guarantee the docs
# make. Require an explicit --no-bitwarden to switch back.
existing_source = proxy_cfg.get("credential_source")
if args.from_bitwarden:
proxy_cfg["credential_source"] = "bitwarden"
elif getattr(args, "no_bitwarden", False):
proxy_cfg["credential_source"] = "env"
if existing_source == "bitwarden":
console.print(
"[yellow]Switched credential_source from bitwarden to env.[/yellow]"
)
elif existing_source == "bitwarden":
# Preserve the existing bitwarden mode. Surface the decision so
# the operator knows we kept it.
console.print(
"[dim]Keeping credential_source=bitwarden from existing config. "
"Pass --no-bitwarden to switch to env-based credentials.[/dim]"
)
else:
proxy_cfg["credential_source"] = "env"
save_config(cfg)
live_status = ip.get_status()
was_running = live_status.pid is not None
if was_running:
ip.stop_proxy()
# Decide whether to (re)start the daemon so the new config/tokens take
# effect, rather than leaving the operator to remember a manual restart
# (the #1 UX papercut for this feature).
# --restart → always (re)start, even if nothing was running
# --no-restart → never; leave it as-is and print the manual hint
# neither + tty → ask (only when a daemon was running)
# neither + !tty → restart when a daemon was running; otherwise no-op
# (first-time setup never auto-starts — matches the
# "configured, now run start" flow)
import sys as _sys
restart_pref = getattr(args, "restart", None)
if restart_pref is True:
do_restart = True
elif restart_pref is False:
do_restart = False
elif was_running:
if _sys.stdin.isatty():
try:
ans = input(
" Restart the running proxy now with the new config? [Y/n] "
).strip().lower()
except EOFError:
ans = ""
do_restart = ans in ("", "y", "yes")
else:
do_restart = True
else:
do_restart = False
if do_restart:
try:
new_status = ip.start_proxy(
install_if_missing=bool(proxy_cfg.get("auto_install", True)),
)
except Exception as exc: # noqa: BLE001 — user-facing funnel
console.print(
f" [yellow]⚠ could not start iron-proxy with the new "
f"config: {exc}[/yellow]"
)
console.print(
" Run [cyan]hermes egress start[/cyan] manually before "
"launching new Docker sandboxes."
)
else:
listening = "listening" if new_status.listening else "not yet listening"
verb = "restarted" if was_running else "started"
console.print(
f" [green]✓[/green] {verb} iron-proxy with the new config "
f"(pid={new_status.pid}, port={new_status.tunnel_port}, {listening})"
)
elif was_running:
console.print(
" [yellow]⚠ stopped the running iron-proxy; config or tokens "
"changed. Run [cyan]hermes egress restart[/cyan] (or "
"[cyan]start[/cyan]) before launching new Docker sandboxes.[/yellow]"
)
console.print()
console.print(
"[green]✓ iron-proxy is configured.[/green] "
"Sandboxes will route outbound traffic through it."
)
console.print(
" Start: [cyan]hermes egress start[/cyan]\n"
" Restart: [cyan]hermes egress restart[/cyan] (after any re-setup)\n"
" Reload: [cyan]hermes egress reload[/cyan] (apply ruleset edits "
"in-place, no restart)\n"
" Status: [cyan]hermes egress status[/cyan]\n"
" Stop: [cyan]hermes egress stop[/cyan]\n"
" Disable: [cyan]hermes egress disable[/cyan]"
)
return 0
def cmd_start(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
if not proxy_cfg.get("enabled"):
console.print(
"[yellow]proxy.enabled is false — run `hermes egress setup` "
"first.[/yellow]"
)
return 1
# If the operator opted in to Bitwarden-rotation semantics, refresh
# upstream secrets from BSM at startup. This is what delivers the
# rotation guarantee that distinguishes ``credential_source:
# bitwarden`` from ``credential_source: env``. Without it, rotating
# a key in the Bitwarden web app doesn't reach the proxy.
credential_source = proxy_cfg.get("credential_source", "env")
bw_cfg = (cfg.get("secrets") or {}).get("bitwarden")
refresh_bw = (
credential_source == "bitwarden"
and bw_cfg is not None
and bool(bw_cfg.get("enabled"))
)
# Silent-degrade guard: the operator explicitly chose
# ``credential_source: bitwarden``, but secrets.bitwarden has since
# been disabled or removed. Proceeding would quietly start on host
# env — exactly the bug class the BW mode is meant to defeat. Refuse
# unless the documented escape hatch is set.
if credential_source == "bitwarden" and not refresh_bw:
if bool(proxy_cfg.get("allow_env_fallback", False)):
console.print(
"[yellow]⚠ credential_source=bitwarden but "
"secrets.bitwarden is disabled or missing — falling back "
"to host-env secrets (allow_env_fallback=true). Rotated "
"Bitwarden keys will NOT propagate.[/yellow]"
)
else:
console.print(
"[red]✗ Refusing to start: proxy.credential_source is "
"'bitwarden' but secrets.bitwarden is disabled or "
"missing.[/red]"
)
console.print(
" Re-enable it (`secrets.bitwarden.enabled: true`), switch "
"back to env credentials with `hermes egress setup "
"--no-bitwarden`, or set `proxy.allow_env_fallback: true` "
"to opt into the host-env fallback."
)
return 1
# Pass the proxy-side allow_env_fallback opt-in through to
# start_proxy. This is a deliberate, documented escape hatch: when
# set, the daemon silently falls back to host env if BWS is
# unreachable, instead of raising. Default is strict (raise).
if refresh_bw and bw_cfg is not None:
bw_cfg = dict(bw_cfg)
bw_cfg["allow_env_fallback"] = bool(
proxy_cfg.get("allow_env_fallback", False)
)
# fail_on_uncovered_providers is intentionally gone: the LLM-specific
# providers it guarded (Anthropic native, Azure OpenAI, Gemini) are now
# swapped via per-provider match_headers rules, so the fail-closed tier
# is empty and the flag would be a dead toggle.
# stephenschoettler #1: when `credential_source: bitwarden`, the
# operator picked BWS specifically to get the rotation guarantee —
# silently falling back to parent-env at start_proxy time reintroduces
# exactly the bug class the BW mode is supposed to defeat (host env
# is stale / mismatched). Pre-check at the wizard layer so we fail
# loud with actionable error messages BEFORE start_proxy degrades.
if refresh_bw:
bw_access_env = (bw_cfg or {}).get("access_token_env", "BWS_ACCESS_TOKEN")
if not os.environ.get(bw_access_env, "").strip():
console.print(
f"[red]✗ Refusing to start: credential_source=bitwarden but "
f"{bw_access_env} is not set in the environment.[/red]"
)
console.print(
" Either export the access token, or run "
"`hermes egress setup --no-bitwarden` to switch back to "
"env-based credentials."
)
return 1
if not (bw_cfg or {}).get("project_id"):
console.print(
"[red]✗ Refusing to start: credential_source=bitwarden but "
"secrets.bitwarden.project_id is empty.[/red]"
)
console.print(
" Run `hermes secrets bitwarden setup` to configure the "
"project, or switch back via `hermes egress setup "
"--no-bitwarden`."
)
return 1
try:
status = ip.start_proxy(
install_if_missing=bool(proxy_cfg.get("auto_install", True)),
refresh_secrets_from_bitwarden=refresh_bw,
bitwarden_config=bw_cfg,
)
except Exception as exc: # noqa: BLE001 — top-level user-facing funnel
console.print(f"[red]✗ failed to start iron-proxy:[/red] {exc}")
return 1
if status.pid:
listening = (
"[green]listening[/green]"
if status.listening
else "[yellow]not yet listening[/yellow]"
)
console.print(
f"[green]✓[/green] iron-proxy running pid={status.pid} "
f"port={status.tunnel_port} {listening}"
)
else:
console.print("[red]✗ iron-proxy did not come up cleanly[/red]")
return 1
return 0
def cmd_stop(args: argparse.Namespace) -> int:
console = Console()
if ip.stop_proxy():
console.print("[green]✓[/green] iron-proxy stopped")
else:
console.print("[dim]iron-proxy was not running[/dim]")
return 0
def cmd_restart(args: argparse.Namespace) -> int:
"""Stop the running daemon (if any) and start it with the current config.
The one-command way to apply config changes (new allowlist hosts, rotated
tokens, a Bitwarden key rotation) without making the operator remember the
stop/start dance. Delegates to ``cmd_start`` so all the credential-source
guards run exactly as they do for ``start``.
"""
console = Console()
was_running = ip.stop_proxy()
if was_running:
console.print("[dim]stopped the running iron-proxy[/dim]")
return cmd_start(args)
def cmd_reload(args: argparse.Namespace) -> int:
"""Hot-reload the running daemon's ruleset via the management API.
Applies allowlist / token / mapping changes already written to
proxy.yaml WITHOUT restarting the daemon no dropped connections, no
restart window. When the change involves new upstream SECRETS (a
Bitwarden rotation, a newly added provider key), use
``hermes egress restart`` instead: the daemon reads real credentials
from its own environment at spawn time, and a reload does not
re-populate that env.
"""
console = Console()
try:
ip.reload_proxy()
except Exception as exc: # noqa: BLE001 — top-level user-facing funnel
console.print(f"[red]✗ reload failed:[/red] {exc}")
return 1
console.print(
"[green]✓[/green] iron-proxy ruleset reloaded in-place "
"(no restart, connections preserved)"
)
console.print(
"[dim]Note: new upstream secrets (rotated keys, new providers) "
"still need `hermes egress restart` — the daemon reads real "
"credentials from its environment at spawn time.[/dim]"
)
return 0
def format_status_text(*, show_tokens: bool = False) -> str:
"""Plain-text egress status for slash commands, Dashboard, and Desktop."""
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
status = ip.get_status()
def yn(value: bool) -> str:
return "yes" if value else "no"
lines = [
"Egress proxy status",
"",
f"Enabled: {yn(bool(proxy_cfg.get('enabled')))}",
f"Binary: {status.binary_path or '(missing)'}",
f"Binary version: {status.binary_version or '(unknown)'}",
f"Config: {status.config_path or '(not generated)'}",
f"CA cert: {status.ca_cert_path or '(not generated)'}",
f"Tunnel port: {status.tunnel_port}",
f"Process: pid {status.pid}" if status.pid else "Process: (stopped)",
f"Listening: {yn(status.listening)}",
f"Credential src: {proxy_cfg.get('credential_source', 'env')}",
f"Docker enforce: {yn(bool(proxy_cfg.get('enforce_on_docker', True)))}",
"Scope: Docker backend only in this release",
]
mappings = ip.load_mappings()
if mappings:
lines.extend(["", "Token mappings:"])
for m in mappings:
tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token)
lines.append(f" - {m.real_env_name}: {tok} ({', '.join(m.upstream_hosts)})")
uncovered = ip.discover_uncovered_providers()
if uncovered:
lines.extend([
"",
"Uncovered providers (real credentials still visible inside the sandbox):",
])
for name in uncovered:
lines.append(f" - {name}")
if bool(proxy_cfg.get("enabled")) and not status.configured:
lines.extend(["", "Next: run `hermes egress setup` to mint tokens and write proxy.yaml."])
elif bool(proxy_cfg.get("enabled")) and not (status.pid and status.listening):
lines.extend(["", "Next: run `hermes egress start` before launching Docker sandboxes."])
return "\n".join(lines)
def cmd_status(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
status = ip.get_status()
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("", style="bold")
table.add_column("")
table.add_row("Enabled", _yn(bool(proxy_cfg.get("enabled"))))
table.add_row("Binary", str(status.binary_path or "[dim](missing)[/dim]"))
table.add_row("Binary version", status.binary_version or "[dim](unknown)[/dim]")
table.add_row("Config", str(status.config_path or "[dim](not generated)[/dim]"))
table.add_row("CA cert", str(status.ca_cert_path or "[dim](not generated)[/dim]"))
table.add_row("Tunnel port", str(status.tunnel_port))
table.add_row("Process", f"pid {status.pid}" if status.pid else "[dim](stopped)[/dim]")
table.add_row("Listening", _yn(status.listening))
table.add_row("Credential src", str(proxy_cfg.get("credential_source", "env")))
table.add_row("Docker enforce", _yn(bool(proxy_cfg.get("enforce_on_docker", True))))
console.print(table)
mappings = ip.load_mappings()
if mappings:
console.print()
console.print("[bold]Token mappings[/bold]")
m_table = Table(show_header=True, header_style="bold")
m_table.add_column("Real env", style="cyan")
m_table.add_column("Upstream", style="dim")
m_table.add_column("Proxy token", style="green")
for m in mappings:
tok = m.proxy_token if args.show_tokens else _redact_token(m.proxy_token)
m_table.add_row(m.real_env_name, ", ".join(m.upstream_hosts), tok)
console.print(m_table)
if args.show_tokens:
console.print(
"[yellow]⚠[/yellow] proxy tokens just printed in full — "
"they may persist in your shell history. Consider clearing "
"it after this command."
)
# Surface uncovered providers so the operator knows the isolation
# boundary is incomplete for those upstreams.
uncovered = ip.discover_uncovered_providers()
if uncovered:
console.print()
console.print(
"[yellow]Uncovered providers[/yellow] "
"(real credentials still visible inside the sandbox):"
)
for name in uncovered:
console.print(f" - {name}")
return 0
def cmd_disable(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
proxy_cfg = cfg.setdefault("proxy", {})
if not proxy_cfg.get("enabled"):
console.print("[dim]proxy.enabled was already false.[/dim]")
return 0
proxy_cfg["enabled"] = False
save_config(cfg)
console.print("[green]✓[/green] proxy.enabled set to false")
# Use the public get_status() pid (which already incorporates the
# _pid_alive check) instead of reaching into ip._read_pid(). That
# private accessor only proves the pidfile is non-empty — a stale
# pidfile from a crashed previous run would fire the warning
# spuriously.
if ip.get_status().pid is not None:
console.print(
" iron-proxy is still running — stop it with "
"[cyan]hermes egress stop[/cyan] if you want it down too."
)
return 0
def cmd_config(args: argparse.Namespace) -> int:
console = Console()
status = ip.get_status()
if status.config_path is None:
console.print(
"[yellow](no config generated — run `hermes egress setup`)[/yellow]"
)
return 1
console.print(str(status.config_path))
return 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_env_file_into_environ() -> int:
"""Backfill provider keys from ``~/.hermes/.env`` into ``os.environ``.
``hermes egress setup`` discovers providers by reading ``os.environ``, but
many operators keep their keys ONLY in ``~/.hermes/.env`` (which the agent
loads at runtime but which is NOT exported into an interactive shell).
Without this, ``setup`` reports "no provider keys found" even though the
keys plainly exist a confusing first-run papercut.
Only fills names that aren't already set in the process env (an exported
value always wins), and only for known bearer-provider names so we don't
slurp unrelated secrets into the process. Returns the count of names added.
"""
try:
from hermes_cli.config import load_env
except ImportError:
return 0
try:
file_env = load_env()
except Exception: # noqa: BLE001 — best-effort convenience, never fatal
return 0
added = 0
known = set(ip._BEARER_PROVIDERS) | set(ip._NON_BEARER_PROVIDERS)
for name in known:
if name in os.environ and os.environ[name].strip():
continue
val = (file_env.get(name) or "").strip()
if val:
os.environ[name] = val
added += 1
return added
def _yn(value: bool) -> str:
return "[green]yes[/green]" if value else "[dim]no[/dim]"
def _redact_token(token: str) -> str:
if len(token) < 16:
return token
return f"{token[:12]}{token[-4:]}"

View file

@ -1234,28 +1234,6 @@ def setup_terminal_backend(config: dict):
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
print()
print_info("Docker sandboxes can be protected with the egress credential firewall.")
print_info(
"It routes sandbox traffic through iron-proxy so containers receive "
"proxy tokens instead of real API keys."
)
print_info(
" Docker only for now; Modal, SSH, Daytona, and Singularity are not wired yet."
)
if prompt_yes_no(" Enable egress firewall for Docker sandboxes?", False):
proxy_cfg = config.setdefault("proxy", {})
proxy_cfg["enabled"] = True
proxy_cfg.setdefault("enforce_on_docker", True)
print_success("Egress firewall enabled in config")
print_info(
"Run `hermes egress setup` then `hermes egress start` to mint "
"tokens and launch the proxy."
)
else:
print_info(
"Skipping egress firewall. You can enable it later with `hermes egress setup`."
)
elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")

View file

@ -626,25 +626,6 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"description": "Modal sandbox mode",
"options": ["sandbox", "function"],
},
"proxy.enabled": {
"type": "boolean",
"description": (
"Docker-only egress credential firewall. Requires `hermes egress setup` "
"and `hermes egress start`; Modal/SSH/Daytona are not wired yet."
),
"category": "security",
},
"proxy.credential_source": {
"type": "select",
"description": "Where iron-proxy loads real upstream secrets at start time",
"options": ["env", "bitwarden"],
"category": "security",
},
"proxy.enforce_on_docker": {
"type": "boolean",
"description": "Refuse Docker sandboxes when egress is enabled but not configured/running",
"category": "security",
},
"tts.provider": {
"type": "select",
"description": "Text-to-speech provider",
@ -4163,14 +4144,6 @@ async def get_schema():
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
@app.get("/api/egress/status")
async def get_egress_status():
"""Dashboard/Desktop-readable egress proxy status and remediation text."""
from hermes_cli.proxy_cli import format_status_text
return {"text": format_status_text()}
_EMPTY_MODEL_INFO: dict = {
"model": "",
"provider": "",

View file

@ -1010,7 +1010,6 @@ AUTHOR_MAP = {
"252620095+briandevans@users.noreply.github.com": "briandevans",
"incharge.automation@gmail.com": "inchargeautomation-lab",
"danielrpike9@gmail.com": "Bartok9",
"kuangmi@deeparchi.com": "kuangmi-bit",
"96944678+ymylive@users.noreply.github.com": "sweetcornna",
"laflamme@illinoisalumni.org": "briancl2",
"skozyuk@cruxexperts.com": "CruxExperts",

View file

@ -32,13 +32,6 @@ def test_status_command_is_available_in_cli_registry():
assert cmd.gateway_only is False
def test_egress_command_is_available_in_cli_registry():
cmd = resolve_command("egress")
assert cmd is not None
assert cmd.gateway_only is False
assert "status" in cmd.subcommands
def test_process_command_status_dispatches_without_toggling_status_bar():
cli_obj = _make_cli()
@ -49,20 +42,6 @@ def test_process_command_status_dispatches_without_toggling_status_bar():
assert cli_obj._status_bar_visible is True
def test_process_command_egress_prints_proxy_status(monkeypatch):
cli_obj = _make_cli()
monkeypatch.setattr(
"hermes_cli.proxy_cli.format_status_text",
lambda: "Egress proxy status\nEnabled: no",
)
assert cli_obj.process_command("/egress") is True
cli_obj.console.print.assert_called()
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
assert "Egress proxy status" in printed
def test_statusbar_still_toggles_visibility():
cli_obj = _make_cli()

View file

@ -146,36 +146,6 @@ async def test_known_slash_command_not_flagged_as_unknown(monkeypatch):
assert "Unknown command" not in result
@pytest.mark.asyncio
async def test_egress_slash_command_reports_proxy_status(monkeypatch):
runner = _make_runner()
monkeypatch.setattr(
"hermes_cli.proxy_cli.format_status_text",
lambda: "Egress proxy status\nEnabled: no",
)
result = await runner._handle_message(_make_event("/egress"))
assert result is not None
assert "Egress proxy status" in result
assert "Unknown command" not in result
@pytest.mark.asyncio
async def test_egress_slash_command_reports_proxy_status_while_agent_running(monkeypatch):
runner = _make_runner()
runner._running_agents[build_session_key(_make_source())] = MagicMock()
monkeypatch.setattr(
"hermes_cli.proxy_cli.format_status_text",
lambda: "Egress proxy status\nEnabled: yes",
)
result = await runner._handle_message(_make_event("/egress"))
assert result is not None
assert "Egress proxy status" in result
@pytest.mark.asyncio
async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch):
"""Telegram autocomplete sends /reload_mcp for the /reload-mcp built-in.

View file

@ -1143,7 +1143,6 @@ class TestTelegramMenuCommands:
assert len(names) == 30
assert hidden > 0
for name in (
"egress",
"debug",
"restart",
"update",

View file

@ -2963,18 +2963,6 @@ class TestBuildSchemaFromConfig:
assert "options" in entry
assert "local" in entry["options"]
def test_proxy_schema_warns_dashboard_users_about_lifecycle(self):
from hermes_cli.web_server import CONFIG_SCHEMA
entry = CONFIG_SCHEMA["proxy.enabled"]
assert entry["category"] == "security"
assert "Docker-only" in entry["description"]
assert "hermes egress setup" in entry["description"]
source_entry = CONFIG_SCHEMA["proxy.credential_source"]
assert source_entry["type"] == "select"
assert source_entry["options"] == ["env", "bitwarden"]
def test_empty_prefix_produces_correct_keys(self):
from hermes_cli.web_server import _build_schema_from_config
test_config = {"model": "test", "nested": {"key": "val"}}

File diff suppressed because it is too large Load diff

View file

@ -1,597 +0,0 @@
"""Unit tests for ``hermes_cli.proxy_cli`` command handlers.
These tests cover the user-facing CLI surface that was previously
uncovered. We mock the iron_proxy module's side-effect functions
(install / start / stop / discover) and exercise the dispatch +
return-code logic plus the small amount of presentation logic in
each handler (e.g. --from-bitwarden's fail-loud path).
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from agent.proxy_sources import iron_proxy as ip
from hermes_cli import proxy_cli
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a temp dir so the wizard doesn't touch the
operator's real config. Also blanks any provider env vars so we
don't accidentally read a real key."""
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for key in list(os.environ):
if key.endswith("_API_KEY") or key in (
"BWS_ACCESS_TOKEN", "ANTHROPIC_API_KEY",
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY",
):
monkeypatch.delenv(key, raising=False)
return home
def _args(**overrides):
ns = argparse.Namespace(
force=False,
tunnel_port=None,
from_bitwarden=False,
no_bitwarden=False,
rotate_tokens=False,
restart=None,
show_tokens=False,
)
for k, v in overrides.items():
setattr(ns, k, v)
return ns
# ---------------------------------------------------------------------------
# cmd_install
# ---------------------------------------------------------------------------
def test_cmd_install_success_returns_0(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "install_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "v0.39.0-test")
rc = proxy_cli.cmd_install(_args())
assert rc == 0
def test_cmd_install_failure_returns_1(hermes_home, monkeypatch):
def boom(**kw):
raise RuntimeError("download failed")
monkeypatch.setattr(ip, "install_iron_proxy", boom)
rc = proxy_cli.cmd_install(_args())
assert rc == 1
# ---------------------------------------------------------------------------
# cmd_setup — --from-bitwarden fail-loud paths
# ---------------------------------------------------------------------------
def test_cmd_setup_from_bitwarden_refuses_when_bw_disabled(hermes_home, monkeypatch):
"""When --from-bitwarden is passed but secrets.bitwarden.enabled=false,
the wizard must FAIL rather than silently rewriting credential_source
to bitwarden."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
save_config(cfg)
# Pre-stub install + CA so we get to step 3.
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
monkeypatch.setattr(
ip, "ensure_ca_cert",
lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"),
)
rc = proxy_cli.cmd_setup(_args(from_bitwarden=True))
assert rc == 1
# Verify we did NOT write credential_source: bitwarden to config.
cfg2 = load_config()
proxy_cfg = cfg2.get("proxy") or {}
assert proxy_cfg.get("credential_source", "env") != "bitwarden"
def test_cmd_setup_from_bitwarden_refuses_when_token_missing(hermes_home, monkeypatch):
"""--from-bitwarden with secrets.bitwarden.enabled=true but BWS access
token unset fail loud, not silent env-fallback."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("secrets", {})["bitwarden"] = {
"enabled": True,
"project_id": "test-proj",
"access_token_env": "BWS_ACCESS_TOKEN",
}
save_config(cfg)
monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False)
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
monkeypatch.setattr(
ip, "ensure_ca_cert",
lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"),
)
rc = proxy_cli.cmd_setup(_args(from_bitwarden=True))
assert rc == 1
def test_cmd_setup_from_bitwarden_refuses_on_empty_vault(hermes_home, monkeypatch):
"""If BW returns {} (empty vault / scoped wrong / unreachable), fail
loud rather than silently writing credential_source: bitwarden."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("secrets", {})["bitwarden"] = {
"enabled": True,
"project_id": "test-proj",
"access_token_env": "BWS_ACCESS_TOKEN",
}
save_config(cfg)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-token")
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
monkeypatch.setattr(
ip, "ensure_ca_cert",
lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"),
)
# Mock fetch_bitwarden_secrets to return an empty dict (empty vault).
fake_bw = MagicMock()
fake_bw.fetch_bitwarden_secrets = lambda **kw: ({}, [])
monkeypatch.setattr("agent.secret_sources.bitwarden", fake_bw, raising=False)
import sys
sys.modules["agent.secret_sources.bitwarden"] = fake_bw
rc = proxy_cli.cmd_setup(_args(from_bitwarden=True))
assert rc == 1
def test_cmd_setup_rejects_tunnel_port_zero(hermes_home, monkeypatch):
"""--tunnel-port=0 is rejected explicitly (was silently substituting
the default before the fix)."""
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
monkeypatch.setattr(
ip, "ensure_ca_cert",
lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"),
)
rc = proxy_cli.cmd_setup(_args(tunnel_port=0))
assert rc == 1
@pytest.mark.parametrize("bad_port", [-1, 65535, 65536])
def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, bad_port):
"""The egress wizard owns the derived HTTP listener at tunnel_port+1,
so both listener ports must fit in the TCP range."""
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
monkeypatch.setattr(
ip,
"ensure_ca_cert",
lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"),
)
rc = proxy_cli.cmd_setup(_args(tunnel_port=bad_port))
assert rc == 1
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def test_cmd_start_refuses_when_proxy_disabled(hermes_home, monkeypatch):
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = False
save_config(cfg)
rc = proxy_cli.cmd_start(_args())
assert rc == 1
def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch):
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["auto_install"] = False
save_config(cfg)
captured: dict = {}
def fake_start_proxy(**kw):
captured.update(kw)
s = ip.ProxyStatus(pid=4242, listening=True, tunnel_port=9090)
return s
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0
assert captured.get("install_if_missing") is False
def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwarden(
hermes_home, monkeypatch,
):
"""When credential_source=bitwarden, cmd_start must wire
refresh_secrets_from_bitwarden=True into start_proxy. That's what
delivers the rotation promise the docs make."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg.setdefault("secrets", {})["bitwarden"] = {
"enabled": True,
"project_id": "test-proj-id",
"access_token_env": "BWS_ACCESS_TOKEN",
}
save_config(cfg)
# v3: cmd_start now pre-checks BWS access token + project_id before
# calling start_proxy. Provide both so we get to the rotation
# wire-up code path.
monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-access-token")
captured: dict = {}
def fake_start_proxy(**kw):
captured.update(kw)
s = ip.ProxyStatus()
s.pid = 4242
s.listening = True
s.tunnel_port = 9090
return s
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0
assert captured.get("refresh_secrets_from_bitwarden") is True
assert captured.get("bitwarden_config") is not None
def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch):
"""stephenschoettler #1: when credential_source=bitwarden but the
access-token env var is empty, cmd_start must fail-loud BEFORE
start_proxy can silently fall back to parent env."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg.setdefault("secrets", {})["bitwarden"] = {
"enabled": True,
"project_id": "test-proj-id",
"access_token_env": "BWS_ACCESS_TOKEN",
}
save_config(cfg)
monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False)
# Sentinel: start_proxy must NOT be called.
def must_not_call(**kw):
pytest.fail("start_proxy should not be invoked when BWS token missing")
monkeypatch.setattr(ip, "start_proxy", must_not_call)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 1
def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env(
hermes_home, monkeypatch,
):
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "env"
save_config(cfg)
captured: dict = {}
def fake_start_proxy(**kw):
captured.update(kw)
s = ip.ProxyStatus()
s.pid = 4242
s.listening = True
return s
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0
assert captured.get("refresh_secrets_from_bitwarden") is False
# ---------------------------------------------------------------------------
# cmd_stop, cmd_status, cmd_disable, cmd_config
# ---------------------------------------------------------------------------
def test_cmd_stop_returns_0_when_running(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "stop_proxy", lambda: True)
rc = proxy_cli.cmd_stop(_args())
assert rc == 0
def test_cmd_stop_returns_0_when_already_stopped(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "stop_proxy", lambda: False)
rc = proxy_cli.cmd_stop(_args())
assert rc == 0
# ---------------------------------------------------------------------------
# cmd_restart
# ---------------------------------------------------------------------------
def test_cmd_restart_stops_then_starts(hermes_home, monkeypatch):
calls = []
monkeypatch.setattr(ip, "stop_proxy", lambda: (calls.append("stop"), True)[1])
monkeypatch.setattr(
proxy_cli, "cmd_start",
lambda args: (calls.append("start"), 0)[1],
)
rc = proxy_cli.cmd_restart(_args())
assert rc == 0
# stop must precede start, and both must run
assert calls == ["stop", "start"]
def test_cmd_restart_starts_even_when_not_previously_running(hermes_home, monkeypatch):
calls = []
monkeypatch.setattr(ip, "stop_proxy", lambda: (calls.append("stop"), False)[1])
monkeypatch.setattr(
proxy_cli, "cmd_start",
lambda args: (calls.append("start"), 0)[1],
)
rc = proxy_cli.cmd_restart(_args())
assert rc == 0
assert calls == ["stop", "start"]
def test_cmd_restart_propagates_start_failure(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "stop_proxy", lambda: True)
monkeypatch.setattr(proxy_cli, "cmd_start", lambda args: 1)
rc = proxy_cli.cmd_restart(_args())
assert rc == 1
# ---------------------------------------------------------------------------
# _load_env_file_into_environ — setup discovers keys kept only in ~/.hermes/.env
# ---------------------------------------------------------------------------
def test_load_env_file_backfills_provider_keys(hermes_home, monkeypatch):
# Key present in .env but NOT exported in the process env.
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_env",
lambda: {"OPENROUTER_API_KEY": "sk-or-from-dotenv", "UNRELATED": "x"},
)
added = proxy_cli._load_env_file_into_environ()
assert added >= 1
assert os.environ.get("OPENROUTER_API_KEY") == "sk-or-from-dotenv"
# Only known provider names are backfilled, not arbitrary secrets.
assert "UNRELATED" not in os.environ
def test_load_env_file_does_not_override_exported_value(hermes_home, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-exported-wins")
monkeypatch.setattr(
"hermes_cli.config.load_env",
lambda: {"OPENROUTER_API_KEY": "sk-or-from-dotenv"},
)
proxy_cli._load_env_file_into_environ()
# An exported value always wins over the .env file.
assert os.environ["OPENROUTER_API_KEY"] == "sk-or-exported-wins"
def test_cmd_status_returns_0(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus())
monkeypatch.setattr(ip, "load_mappings", lambda: [])
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_status(_args())
assert rc == 0
def test_cmd_disable_uses_public_status_pid_not_private_read_pid(
hermes_home, monkeypatch,
):
"""cmd_disable must read status.pid (which incorporates the _pid_alive
check) NOT ip._read_pid() directly (which would fire a spurious
'still running' warning for a stale pidfile from a crashed run)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
save_config(cfg)
# Pidfile exists but the process is dead. Old code would have warned
# "still running"; the new code reads status.pid which returns None
# because _pid_alive is False, so no spurious warning.
state = ip._proxy_state_dir()
(state / "iron-proxy.pid").write_text("99999")
# _pid_alive returns False → status.pid is None.
monkeypatch.setattr(ip, "_pid_alive", lambda pid: False)
# If cmd_disable reads _read_pid() directly (old path), this test
# would still pass — but reading status.pid is the correct
# API. Sentinel: confirm _read_pid is NOT called from cmd_disable.
read_pid_calls = []
real_read_pid = ip._read_pid
def tracked_read_pid(*a, **kw):
read_pid_calls.append((a, kw))
return real_read_pid(*a, **kw)
monkeypatch.setattr(ip, "_read_pid", tracked_read_pid)
rc = proxy_cli.cmd_disable(_args())
assert rc == 0
# cmd_disable should call get_status() (which may internally call
# _read_pid), but should NOT call _read_pid from its own body.
# Hard to assert directly without source-introspection — the meatier
# assertion is that no "still running" message fired with a stale
# pidfile. That's covered by inspecting return code + config
# mutation only.
from hermes_cli.config import load_config as _lc
cfg2 = _lc()
assert cfg2["proxy"]["enabled"] is False
def test_cmd_config_returns_0_when_present(hermes_home, monkeypatch):
fake = ip.ProxyStatus()
fake.config_path = hermes_home / "proxy.yaml"
monkeypatch.setattr(ip, "get_status", lambda: fake)
rc = proxy_cli.cmd_config(_args())
assert rc == 0
def test_cmd_config_returns_1_when_missing(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus())
rc = proxy_cli.cmd_config(_args())
assert rc == 1
# ---------------------------------------------------------------------------
# Argparse wiring — dest='egress_command' regression
# ---------------------------------------------------------------------------
def test_register_cli_uses_egress_command_dest():
"""The subparser dest must be 'egress_command' to stay disjoint from
the inbound OAuth 'hermes proxy' subparser (dest='proxy_command').
A future grep-and-refactor on proxy_command should not hit this
subparser by accident."""
parser = argparse.ArgumentParser(prog="hermes egress")
proxy_cli.register_cli(parser)
# Parse a no-op invocation and confirm the attribute name.
args = parser.parse_args(["install"])
assert hasattr(args, "egress_command")
assert not hasattr(args, "proxy_command")
def test_egress_subcommands_registered():
"""Smoke test: every documented subcommand parses without error."""
parser = argparse.ArgumentParser(prog="hermes egress")
proxy_cli.register_cli(parser)
for sub in ("install", "setup", "start", "stop", "status", "disable", "config"):
args = parser.parse_args([sub])
assert args.egress_command == sub
def test_setup_has_rotate_tokens_flag():
"""--rotate-tokens is the documented escape hatch for re-rolling
every proxy token (used after a suspected token leak). Default is
preserve-existing."""
parser = argparse.ArgumentParser(prog="hermes egress")
proxy_cli.register_cli(parser)
args = parser.parse_args(["setup"])
assert args.rotate_tokens is False
args = parser.parse_args(["setup", "--rotate-tokens"])
assert args.rotate_tokens is True
# ---------------------------------------------------------------------------
# v4 round: credential_source=bitwarden with secrets.bitwarden disabled
# must NOT silently degrade to host-env secrets
# ---------------------------------------------------------------------------
def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeypatch):
"""config keeps credential_source: bitwarden but secrets.bitwarden.enabled
later flips to false cmd_start must refuse, not silently start on
host env (the silent-degrade class strict mode is meant to close)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
save_config(cfg)
def must_not_call(**kw):
pytest.fail("start_proxy must not run when bitwarden mode is broken")
monkeypatch.setattr(ip, "start_proxy", must_not_call)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 1
def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback(
hermes_home, monkeypatch,
):
"""Same scenario but proxy.allow_env_fallback=true is the documented
escape hatch start proceeds (with a warning)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg["proxy"]["allow_env_fallback"] = True
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
save_config(cfg)
captured: dict = {}
def fake_start_proxy(**kw):
captured.update(kw)
s = ip.ProxyStatus()
s.pid = 4242
s.listening = True
return s
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0
# BW refresh is off (bitwarden disabled), running on env secrets.
assert captured.get("refresh_secrets_from_bitwarden") is False
def test_cmd_setup_audit_log_failure_is_warning_not_abort(hermes_home, monkeypatch):
"""On the pinned v0.39 the daemon never writes audit.log, so a
pre-create failure must not abort the wizard."""
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "discover_provider_mappings", lambda **kw: [
ip.TokenMapping(
proxy_token="hermes-proxy-deadbeef",
real_env_name="OPENROUTER_API_KEY",
upstream_hosts=("openrouter.ai",),
),
])
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
def boom(path):
raise RuntimeError("could not pre-create audit log (synthetic)")
monkeypatch.setattr(ip, "ensure_audit_log", boom)
rc = proxy_cli.cmd_setup(_args())
assert rc == 0

View file

@ -1,368 +0,0 @@
"""End-to-end smoke test for the iron-proxy egress integration.
Spins up the REAL iron-proxy binary (auto-installed if not present), routes
a curl request through it against a local fake upstream, and verifies that
the Authorization header was swapped from a proxy token to a real secret.
Gated on the network. Skipped by default in CI unless the user explicitly
opts in with --run-e2e or HERMES_RUN_E2E=1. This is intentional the test
downloads ~16MB and requires both `openssl` and `curl` to be present.
"""
from __future__ import annotations
import os
import socket
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Optional
import pytest
from agent.proxy_sources import iron_proxy as ip
pytestmark = pytest.mark.skipif(
os.environ.get("HERMES_RUN_E2E", "0") != "1",
reason="E2E proxy test — set HERMES_RUN_E2E=1 to run (requires network + curl + openssl)",
)
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _CaptureHandler(BaseHTTPRequestHandler):
"""Records the Authorization header of every incoming request."""
captured_auth: Optional[str] = None # class-level so tests can read it
def do_GET(self):
type(self).captured_auth = self.headers.get("Authorization")
body = b'{"ok": true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args, **kwargs):
return # silence access log
def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypatch):
"""Real binary, real CA, real curl. Verify the proxy swaps a proxy-token
Authorization header for the real bearer value before forwarding."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
# ----- fake upstream ----------------------------------------------------
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
try:
# ----- iron-proxy install + CA + config ---------------------------
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
assert ca_crt.exists()
real_secret = "sk-real-upstream-value-deadbeef"
monkeypatch.setenv("TEST_UPSTREAM_KEY", real_secret)
proxy_token = ip.mint_proxy_token("test")
mapping = ip.TokenMapping(
proxy_token=proxy_token,
real_env_name="TEST_UPSTREAM_KEY",
upstream_hosts=("127.0.0.1",),
)
tunnel_port = _free_port()
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
# Test target is on loopback — clear the default IMDS+loopback
# deny list so iron-proxy will dial 127.0.0.1.
upstream_deny_cidrs=[],
# Hermetic: pin the bind to loopback. Without this, Linux
# hosts with docker0 present would bind the bridge gateway
# (the production default) and the loopback curl below would
# never reach the proxy.
http_listen=[f"127.0.0.1:{tunnel_port}"],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
# ----- start the proxy --------------------------------------------
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
assert status.pid is not None
# Wait up to 10s for the listener to come up.
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening on the tunnel port")
# ----- request through the proxy ----------------------------------
# The fake upstream listens on plain HTTP (not HTTPS). Plain-HTTP
# absolute-form forwards are served by the http_listen listener on
# tunnel_port + 1 (tunnel_port itself is the CONNECT/MITM listener
# that HTTPS_PROXY traffic hits). The secrets transform fires on
# the plain forward too, swapping the Authorization header.
result = subprocess.run(
[
"curl",
"--silent",
"--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"Authorization: Bearer {proxy_token}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
# Some iron-proxy versions return 200 with no body; only the swap matters.
captured = _CaptureHandler.captured_auth
assert captured is not None, "upstream never received the request"
assert real_secret in captured, (
f"Authorization header was not swapped — upstream saw: {captured!r}"
)
assert proxy_token not in captured, (
f"Proxy token leaked through to upstream: {captured!r}"
)
finally:
# ----- cleanup ------------------------------------------------------
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()
class _CaptureXApiKeyHandler(BaseHTTPRequestHandler):
"""Records the x-api-key header of every incoming request."""
captured_key: Optional[str] = None
def do_GET(self):
type(self).captured_key = self.headers.get("x-api-key")
body = b'{"ok": true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args, **kwargs):
return
def test_iron_proxy_swaps_x_api_key_header_end_to_end(hermes_home, monkeypatch):
"""Header-auth providers: the secrets transform must swap the proxy
token out of a NON-Authorization header (x-api-key the Anthropic
native scheme) on the pinned binary."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureXApiKeyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
real_secret = "sk-ant-real-value-cafebabe"
monkeypatch.setenv("TEST_XAPI_KEY", real_secret)
proxy_token = ip.mint_proxy_token("anthropic")
mapping = ip.TokenMapping(
proxy_token=proxy_token,
real_env_name="TEST_XAPI_KEY",
upstream_hosts=("127.0.0.1",),
match_headers=("x-api-key", "Authorization"),
)
tunnel_port = _free_port()
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
upstream_deny_cidrs=[],
http_listen=[f"127.0.0.1:{tunnel_port}"],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
assert status.pid is not None
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening on the tunnel port")
result = subprocess.run(
[
"curl",
"--silent",
"--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"x-api-key: {proxy_token}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
captured = _CaptureXApiKeyHandler.captured_key
assert captured is not None, "upstream never received the request"
assert real_secret in captured, (
f"x-api-key header was not swapped — upstream saw: {captured!r}"
)
assert proxy_token not in captured, (
f"Proxy token leaked through to upstream: {captured!r}"
)
finally:
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()
def test_iron_proxy_management_reload_end_to_end(hermes_home, monkeypatch):
"""Real binary: the management listener comes up, an authenticated
POST /v1/reload succeeds after a config edit, and the edited ruleset
takes effect WITHOUT a restart (same pid)."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
real_secret = "sk-real-reload-value-0badf00d"
monkeypatch.setenv("TEST_RELOAD_KEY", real_secret)
token_v1 = ip.mint_proxy_token("v1")
def _write_cfg(mapping):
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
upstream_deny_cidrs=[],
http_listen=[f"127.0.0.1:{tunnel_port}"],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
tunnel_port = _free_port()
_write_cfg(ip.TokenMapping(
proxy_token=token_v1,
real_env_name="TEST_RELOAD_KEY",
upstream_hosts=("127.0.0.1",),
))
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
pid_before = status.pid
assert pid_before is not None
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening")
# Rotate the sandbox-visible token in the config, then hot-reload.
token_v2 = ip.mint_proxy_token("v2")
_write_cfg(ip.TokenMapping(
proxy_token=token_v2,
real_env_name="TEST_RELOAD_KEY",
upstream_hosts=("127.0.0.1",),
))
assert ip.reload_proxy() is True
# Same daemon (no restart) ...
assert ip.get_status().pid == pid_before
# ... but the NEW token now swaps.
_CaptureHandler.captured_auth = None
result = subprocess.run(
[
"curl", "--silent", "--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"Authorization: Bearer {token_v2}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True, text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
captured = _CaptureHandler.captured_auth
assert captured is not None and real_secret in captured, (
f"post-reload token was not swapped — upstream saw: {captured!r}"
)
finally:
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()

View file

@ -45,13 +45,11 @@ def _make_dummy_env(**kwargs):
persistent_filesystem=kwargs.get("persistent_filesystem", False),
task_id=kwargs.get("task_id", "test-task"),
volumes=kwargs.get("volumes", []),
forward_env=kwargs.get("forward_env"),
network=kwargs.get("network", True),
host_cwd=kwargs.get("host_cwd"),
auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
env=kwargs.get("env"),
run_as_host_user=kwargs.get("run_as_host_user", False),
extra_args=kwargs.get("extra_args", []),
persist_across_processes=kwargs.get("persist_across_processes", True),
)
@ -364,52 +362,6 @@ def test_docker_env_appears_in_run_command(monkeypatch):
assert "GNUPGHOME=/root/.gnupg" in run_args_str
def _node_options_from_run(calls):
run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"]
assert run_calls, "docker run should have been called"
args = run_calls[0][0]
for i, a in enumerate(args):
if a == "-e" and i + 1 < len(args) and args[i + 1].startswith("NODE_OPTIONS="):
return args[i + 1].split("=", 1)[1]
return None
def test_egress_node_options_overrides_conflicting_ca_flag(monkeypatch):
"""maxpetrusenko P1: a conflicting docker_env NODE_OPTIONS CA-mode flag
(--use-bundled-ca) must be replaced by the egress-required --use-openssl-ca,
not left to survive alongside it (final Node trust would depend on order)."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env, "_egress_proxy_args_for_docker",
lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []),
)
calls = _mock_subprocess_run(monkeypatch)
_make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=8192 --use-bundled-ca"})
node_opts = (_node_options_from_run(calls) or "").split()
assert "--use-openssl-ca" in node_opts, "egress CA flag must be present"
assert "--use-bundled-ca" not in node_opts, "conflicting CA flag must be stripped"
# Operator's unrelated tuning must be preserved.
assert "--max-old-space-size=8192" in node_opts
def test_egress_node_options_preserves_operator_tuning(monkeypatch):
"""Non-conflicting operator NODE_OPTIONS survive the egress append-merge."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env, "_egress_proxy_args_for_docker",
lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []),
)
calls = _mock_subprocess_run(monkeypatch)
_make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=4096"})
node_opts = (_node_options_from_run(calls) or "").split()
assert "--use-openssl-ca" in node_opts
assert "--max-old-space-size=4096" in node_opts
def test_docker_env_appears_in_init_env_args(monkeypatch):
"""Explicit docker_env values should appear in _build_init_env_args."""
env = _make_execute_only_env()
@ -727,7 +679,6 @@ def test_labels_attribute_populated_after_init(monkeypatch):
"hermes-agent": "1",
"hermes-task-id": "abc",
"hermes-profile": "default",
"hermes-egress": "off",
}
@ -759,13 +710,8 @@ def _mock_subprocess_run_with_reuse(monkeypatch, ps_state: str | None,
if sub == "ps":
if ps_state is None:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
# 3-field format: ID, State, EgressLabel. When egress_label
# is "off" the code parses all three fields; <no value> means
# the container has no egress label, which is acceptable.
return subprocess.CompletedProcess(
cmd, 0,
stdout=f"reused-cid\t{ps_state}\t<no value>\n",
stderr="",
cmd, 0, stdout=f"reused-cid\t{ps_state}\n", stderr="",
)
if sub == "start":
if not start_succeeds:
@ -808,95 +754,6 @@ def test_reuse_attaches_to_running_container_without_docker_run(monkeypatch):
)
def test_egress_enabled_does_not_reuse_pre_egress_container(monkeypatch):
"""A container created before egress was enabled lacks the proxy env vars
and CA mount. Reusing it would silently bypass the credential firewall."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default")
monkeypatch.setattr(
docker_env,
"_egress_proxy_args_for_docker",
lambda: (
["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"],
{"HTTPS_PROXY": "http://host.docker.internal:9090"},
["--add-host", "host.docker.internal:host-gateway"],
),
)
calls = []
def _run(cmd, **kwargs):
calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))
if isinstance(cmd, list) and len(cmd) >= 2:
sub = cmd[1]
if sub == "version":
return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="")
if sub == "ps":
# Simulate an old pre-egress container: without the egress label
# filter it would match; with the filter Docker returns no match.
assert any(str(part).startswith("label=hermes-egress=") for part in cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if sub == "run":
return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
env = _make_dummy_env(task_id="reuse-egress")
assert env._container_id == "fresh-cid"
run_invocations = [
c for c in calls
if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"
]
assert run_invocations, "egress-enabled containers require a fresh docker run"
def test_forward_env_provider_key_collision_refuses_under_egress(monkeypatch):
"""docker_forward_env is explicit, but it still must not smuggle real
provider keys into an enforced egress sandbox."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-real")
monkeypatch.setattr(
docker_env,
"_egress_proxy_args_for_docker",
lambda: (
[],
{
"HTTPS_PROXY": "http://host.docker.internal:9090",
"OPENROUTER_API_KEY": "hermes-proxy-openrouter-token",
"HERMES_PROXY_TOKEN_OPENROUTER_API_KEY": "hermes-proxy-openrouter-token",
},
[],
),
)
_mock_subprocess_run(monkeypatch)
with pytest.raises(RuntimeError, match="docker_forward_env.*OPENROUTER_API_KEY"):
_make_dummy_env(forward_env=["OPENROUTER_API_KEY"])
def test_extra_args_proxy_override_refuses_under_egress(monkeypatch):
"""docker_extra_args are appended after Hermes args, so egress enforcement
must reject critical overrides before Docker sees them."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env,
"_egress_proxy_args_for_docker",
lambda: (
[],
{"HTTPS_PROXY": "http://host.docker.internal:9090"},
[],
),
)
_mock_subprocess_run(monkeypatch)
with pytest.raises(RuntimeError, match="docker_extra_args.*HTTPS_PROXY"):
_make_dummy_env(extra_args=["-e", "HTTPS_PROXY="])
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
@ -1051,11 +908,10 @@ def test_find_reusable_container_prefers_running_over_stopped(monkeypatch):
if cmd[1] == "version":
return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="")
if cmd[1] == "ps":
# Two matches: stopped first, running second. 3-field format
# with absent egress label for the "off" path.
# Two matches: stopped first, running second.
return subprocess.CompletedProcess(
cmd, 0,
stdout="stopped-cid\texited\t<no value>\nrunning-cid\trunning\t<no value>\n",
stdout="stopped-cid\texited\nrunning-cid\trunning\n",
stderr="",
)
return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="")
@ -1068,71 +924,6 @@ def test_find_reusable_container_prefers_running_over_stopped(monkeypatch):
)
def test_find_reusable_handles_empty_label_string(monkeypatch):
"""Docker CLI v29.5.3 returns an empty string (NOT ``<no value>``)
for absent labels. The trailing tab produces ``cid\\trunning\\t\\n``;
we must not strip the trailing tab or the three-field parser drops the
container. Regression test for the egilewski review on #48073."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default")
def _run(cmd, **kwargs):
if isinstance(cmd, list) and len(cmd) >= 2:
if cmd[1] == "version":
return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="")
if cmd[1] == "ps":
# Docker v29.5.3: absent label → empty string, trailing tab
return subprocess.CompletedProcess(
cmd, 0,
stdout="safe-cid\trunning\t\n",
stderr="",
)
return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
env = _make_dummy_env(task_id="empty-label")
assert env._container_id == "safe-cid", (
f"container with empty-string label should be reused, got {env._container_id!r}"
)
def test_reuse_off_rejects_non_off_egress_container(monkeypatch):
"""When egress is off, a container that still has hermes-egress=on
(e.g. from before ``hermes egress disable``) must be rejected and a
fresh container created. The post-filter protects against silently
reusing a container with baked-in proxy env and CA mounts."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default")
def _run(cmd, **kwargs):
if isinstance(cmd, list) and len(cmd) >= 2:
if cmd[1] == "version":
return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="")
if cmd[1] == "ps":
# Return a container with hermes-egress=on. With egress=off
# the three-field format includes the label; the post-filter
# must skip this entry.
return subprocess.CompletedProcess(
cmd, 0,
stdout="stale-cid\trunning\ton\n",
stderr="",
)
if cmd[1] == "run":
return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
env = _make_dummy_env(task_id="egress-off-reject")
# Should fall through to fresh container because the stale one has
# hermes-egress=on.
assert env._container_id == "fresh-cid", (
f"expected fresh container, got {env._container_id!r}"
)
# ── Cleanup correctness (issue #20561) ────────────────────────────

View file

@ -5,7 +5,6 @@ configurable resource limits (CPU, memory, disk), and optional filesystem
persistence via bind mounts.
"""
import hashlib
import json
import logging
import os
@ -37,7 +36,6 @@ _DOCKER_SEARCH_PATHS = [
_docker_executable: Optional[str] = None # resolved once, cached
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_EGRESS_LABEL_KEY = "hermes-egress"
def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]:
@ -366,247 +364,6 @@ _PRIVDROP_CAP_ARGS = [
]
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.
Returns ``(volume_args, env_overrides, host_args)``:
* ``volume_args`` read-only bind mount of the CA cert into the container
(extends docker's ``-v`` argv list)
* ``env_overrides`` env vars to set on container creation: ``HTTPS_PROXY``,
``HTTP_PROXY``, ``NO_PROXY`` (loopback only), Python/Node/curl CA-bundle
paths, and one ``HERMES_PROXY_TOKEN_<NAME>`` per minted mapping
* ``host_args`` extra ``--add-host`` flags so the container can reach the
host-side proxy (Linux needs ``host.docker.internal:host-gateway``;
Docker Desktop populates this automatically on macOS/Windows)
Returns three empty containers when the proxy is disabled, not yet set up,
or not currently running. If ``proxy.enforce_on_docker`` is true and the
proxy is enabled-but-not-running, raises ``RuntimeError`` so the docker
backend refuses to start the sandbox.
"""
# Narrow except: ImportError is the only legitimate failure here.
# Bare ``except Exception`` would hide AttributeError, SyntaxError in
# the config module, etc. and silently start the sandbox without
# proxy enforcement. We let unexpected exceptions propagate so the
# docker backend visibly fails rather than degrading silently.
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 ([], {}, [])
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
if not proxy_cfg.get("enabled"):
return ([], {}, [])
status = ip.get_status()
enforce = bool(proxy_cfg.get("enforce_on_docker", True))
if not status.configured:
msg = (
"proxy.enabled is true but iron-proxy is not configured. "
"Run `hermes egress setup` to mint tokens and write proxy.yaml."
)
if enforce:
raise RuntimeError(msg)
logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg)
return ([], {}, [])
if not (status.pid and status.listening):
msg = (
f"iron-proxy is enabled but not running on port {status.tunnel_port}. "
"Start it with `hermes egress start`."
)
if enforce:
raise RuntimeError(msg)
logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg)
return ([], {}, [])
if status.ca_cert_path is None or not status.ca_cert_path.exists():
# status.configured was True a moment ago but the CA file has
# disappeared. Treat this with the same enforce semantics as the
# other failure branches — silently dropping the CA mount would
# leave the sandbox with proxy env vars pointing at iron-proxy
# but no trust anchor, so every TLS handshake would 5xx; or
# worse, with enforce_on_docker=false we'd drop both the proxy
# vars AND any other isolation, opening the sandbox.
msg = (
f"iron-proxy CA cert vanished from {status.ca_cert_path}. "
"Re-run `hermes egress setup` to regenerate it."
)
if enforce:
raise RuntimeError(msg)
logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg)
return ([], {}, [])
# Corrupt or empty mappings.json is a silent failure mode that's
# 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 "
"corrupt. Re-run `hermes egress setup` to mint provider "
"tokens before starting a sandbox."
)
if enforce:
raise RuntimeError(msg)
logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg)
return ([], {}, [])
container_ca = "/etc/ssl/certs/hermes-egress-ca.crt"
volume_args = ["-v", f"{status.ca_cert_path}:{container_ca}:ro"]
# tunnel_port serves CONNECT (HTTPS); the plain-HTTP forward listener
# is on tunnel_port + 1 (see build_proxy_config's listener-role notes).
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] = {
# HTTPS_PROXY / HTTP_PROXY are respected by curl, requests, urllib,
# httpx, node fetch, go default transport, etc. Lowercase variants
# are also set because some tools only look at one casing.
"HTTPS_PROXY": proxy_url,
"https_proxy": proxy_url,
"HTTP_PROXY": plain_http_url,
"http_proxy": plain_http_url,
# Loopback-only NO_PROXY so localhost dev servers inside the sandbox
# (test fixtures, local LLMs) don't get sent through the proxy.
"NO_PROXY": "127.0.0.1,localhost,::1",
"no_proxy": "127.0.0.1,localhost,::1",
# CA bundle locations for the major language runtimes. iron-proxy
# presents a leaf cert signed by our CA on every MITM'd connection.
#
# CRITICAL ASYMMETRY: Python (REQUESTS_CA_BUNDLE / SSL_CERT_FILE)
# and curl (CURL_CA_BUNDLE) REPLACE the system CA store.
# NODE_EXTRA_CA_CERTS ADDS to it. A Node.js process that
# bypasses HTTPS_PROXY by using a raw socket would still see the
# system CA store and succeed where Python/curl fail validation.
# We additionally set NODE_OPTIONS=--use-openssl-ca to force Node
# through the OpenSSL store that SSL_CERT_FILE controls, narrowing
# the asymmetry. Not a complete fix — see the docs caveat — but
# closes the easy case.
"REQUESTS_CA_BUNDLE": container_ca, # Python `requests`
"SSL_CERT_FILE": container_ca, # Python ssl module / OpenSSL
"CURL_CA_BUNDLE": container_ca, # curl
"NODE_EXTRA_CA_CERTS": container_ca, # Node.js: adds to system store
# NOTE: NODE_OPTIONS is intentionally NOT placed in env_overrides
# here as a flat assignment. We need to APPEND --use-openssl-ca
# to whatever the user already has in NODE_OPTIONS (e.g.
# --max-old-space-size=4096), not clobber it. The append-merge
# happens in DockerEnvironment._merge_node_options below.
# For the agent inside the sandbox to identify itself as proxy-aware.
"HERMES_EGRESS_PROXY": "1",
# Sentinel that DockerEnvironment uses to do the NODE_OPTIONS
# append-merge. Stripped from the final env before docker run.
"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca",
}
# Surface the per-provider proxy tokens under the standard provider env
# names so existing SDKs and provider clients work unchanged inside the
# sandbox. Alias env names (e.g. GOOGLE_API_KEY for GEMINI_API_KEY)
# receive the same token so SDKs reading either name authenticate
# through the proxy. Keep the HERMES_PROXY_TOKEN_* aliases 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
# On Linux, host.docker.internal isn't populated by default — Docker Desktop
# adds it on macOS/Windows; on Linux we need an explicit --add-host with
# host-gateway. On Desktop this is a no-op (harmless duplicate).
host_args: list[str] = ["--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 with fail-safe defaulting."""
try:
from hermes_cli.config import load_config as _load_cfg
return bool((_load_cfg().get("proxy") or {}).get("enforce_on_docker", default))
except (ImportError, OSError):
return default
except Exception:
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 = {
"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", "NODE_OPTIONS",
}
critical.update(
key for key in env_overrides
if key.endswith("_API_KEY") or key.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] = []
env_flags = {"-e", "--env", "--env-file"}
network_flags = {"--network", "--net"}
i = 0
while i < len(extra_args):
arg = extra_args[i]
nxt = extra_args[i + 1] if i + 1 < len(extra_args) else ""
if arg in env_flags:
if arg == "--env-file":
collisions.append(arg)
else:
name = nxt.split("=", 1)[0]
if name in critical_names:
collisions.append(name)
i += 2
continue
if any(arg.startswith(f"{flag}=") for flag in env_flags):
if arg.startswith("--env-file="):
collisions.append("--env-file")
else:
name = arg.split("=", 1)[1].split("=", 1)[0]
if name in critical_names:
collisions.append(name)
elif arg in network_flags or any(arg.startswith(f"{flag}=") for flag in network_flags):
collisions.append(arg)
i += 1
return sorted(set(collisions))
def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]:
"""Return the security/cap/tmpfs args tailored to the privilege mode.
@ -1029,195 +786,11 @@ class DockerEnvironment(BaseEnvironment):
except Exception as e:
logger.debug("Docker: could not load credential file mounts: %s", e)
# Egress credential-injection proxy (iron-proxy) — when configured,
# mount the CA cert into the sandbox and set HTTPS_PROXY + CA-bundle
# env vars so outbound traffic routes through the host-side proxy.
# The sandbox receives PROXY tokens instead of real API keys.
egress_volume_args, egress_env_overrides, egress_host_args = (
_egress_proxy_args_for_docker()
)
egress_label = _egress_reuse_fingerprint(
egress_volume_args, egress_env_overrides, egress_host_args,
)
_enforce_egress = _egress_enforce_on_docker()
_critical_egress_names = _critical_egress_env_names(egress_env_overrides)
if egress_env_overrides:
_forward_collisions = sorted(
key for key in self._forward_env if key in _critical_egress_names
)
if _forward_collisions:
_msg = (
f"docker_forward_env would inject real egress-protected "
f"variables {_forward_collisions}; enforce_on_docker is "
f"{'enabled' if _enforce_egress else 'disabled'}."
)
if _enforce_egress:
raise RuntimeError(
f"{_msg} Remove these names from docker_forward_env "
"or disable enforce_on_docker to opt out of egress isolation."
)
logger.warning(
"%s Explicit docker_forward_env values will override egress tokens.",
_msg,
)
volume_args.extend(egress_volume_args)
# egress env overrides are merged in further below alongside the
# other env_args computation.
# Explicit environment variables (docker_env config) — set at container
# creation so they're available to all processes (including entrypoint).
# Egress proxy env vars (HTTPS_PROXY, CA-bundle paths, proxy tokens)
# are merged below. Precedence policy:
#
# - When egress enforcement is on AND the user's docker_env tries
# to override one of the proxy-control vars (HTTPS_PROXY,
# SSL_CERT_FILE, etc.), fail-loud rather than silently inverting
# the isolation. The CA mount + tokens would still ship while
# traffic leaves the sandbox direct with real credentials —
# exactly what enforce_on_docker is meant to prevent.
# - When enforcement is off, the user's docker_env wins (current
# 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
_critical = _critical_proxy_control | _critical_provider_keys
_collisions = sorted(
k for k in _critical
if k in self._env
and (
k not in egress_env_overrides
or self._env[k] != egress_env_overrides[k]
)
# For provider keys, ANY override is a collision (the egress
# path mints proxy tokens; a real key in docker_env bypasses
# the swap regardless of whether the egress dict happens to
# carry it).
and (
k in _critical_provider_keys
or (k in egress_env_overrides
and self._env[k] != egress_env_overrides[k])
)
)
if _collisions:
_msg = (
f"docker_env in config.yaml overrides egress-proxy "
f"variables {_collisions}; enforce_on_docker is "
f"{'enabled' if _enforce_egress else 'disabled'}."
)
if _enforce_egress:
raise RuntimeError(
f"{_msg} Remove these keys from docker_env or "
"disable enforce_on_docker to opt out of egress "
"isolation."
)
logger.warning(
"%s Falling back to docker_env values; sandbox traffic "
"will NOT route through the proxy.", _msg,
)
# When enforce_on_docker is true, egress overrides win. When
# 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:
merged_env = dict(self._env)
merged_env.update(egress_env_overrides)
else:
merged_env = dict(egress_env_overrides)
merged_env.update(self._env)
# arshkumarsingh #1: NODE_OPTIONS append-merge. The egress path
# wants ``--use-openssl-ca`` so Node routes through the OpenSSL
# CA store ``SSL_CERT_FILE`` controls. But the operator's
# ``docker_env: {NODE_OPTIONS: "--max-old-space-size=8192"}``
# MUST be preserved — replacing it would silently drop their
# tuning. We carry the egress flag in a sentinel key
# ``_HERMES_EGRESS_NODE_OPTIONS_APPEND`` and merge here.
_egress_node_append = merged_env.pop(
"_HERMES_EGRESS_NODE_OPTIONS_APPEND", None,
)
if _egress_node_append:
existing_node = merged_env.get("NODE_OPTIONS", "")
existing_tokens = existing_node.split()
# maxpetrusenko P1: dedupe is not enough — the operator may have set
# a CONFLICTING CA-mode flag (e.g. --use-bundled-ca) that would
# otherwise survive alongside our --use-openssl-ca, leaving Node's
# final trust behavior dependent on option order / Node parsing.
# Egress isolation requires our flag to win deterministically, so
# strip any known-conflicting CA-mode flags before appending.
_CA_MODE_FLAGS = {"--use-openssl-ca", "--use-bundled-ca"}
append_token = _egress_node_append.strip()
if append_token in _CA_MODE_FLAGS:
dropped = [t for t in existing_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,
)
existing_tokens = [t for t in existing_tokens if t not in _CA_MODE_FLAGS or t == append_token]
# De-dup: only add if not already present (the operator may
# have set the same flag themselves).
if append_token not in existing_tokens:
existing_tokens.append(append_token)
merged_env["NODE_OPTIONS"] = " ".join(existing_tokens).strip()
if not merged_env["NODE_OPTIONS"]:
merged_env.pop("NODE_OPTIONS", None)
env_args = []
for key in sorted(merged_env):
env_args.extend(["-e", f"{key}={merged_env[key]}"])
for key in sorted(self._env):
env_args.extend(["-e", f"{key}={self._env[key]}"])
# Optional: run the container as the host user so files written into
# bind-mounted dirs (/workspace, /root, docker_volumes entries) are
@ -1269,31 +842,12 @@ class DockerEnvironment(BaseEnvironment):
logger.warning("Ignoring non-string docker_extra_args entry: %r", arg)
continue
validated_extra.append(arg)
if egress_env_overrides:
_extra_collisions = _extra_args_egress_collisions(
validated_extra, _critical_egress_names,
)
if _extra_collisions:
_msg = (
f"docker_extra_args would override egress-proxy controls "
f"{_extra_collisions}; enforce_on_docker is "
f"{'enabled' if _enforce_egress else 'disabled'}."
)
if _enforce_egress:
raise RuntimeError(
f"{_msg} Remove these args or disable enforce_on_docker "
"to opt out of egress isolation."
)
logger.warning(
"%s Extra Docker args may bypass egress isolation.", _msg,
)
all_run_args = (
security_args
+ user_args
+ writable_args
+ resource_args
+ egress_host_args
+ volume_args
+ env_args
+ validated_extra
@ -1315,7 +869,6 @@ class DockerEnvironment(BaseEnvironment):
"--label", "hermes-agent=1",
"--label", f"hermes-task-id={task_label}",
"--label", f"hermes-profile={profile_name}",
"--label", f"{_EGRESS_LABEL_KEY}={egress_label}",
]
# Save args for container recreation on "No such container" recovery.
self._image = image
@ -1327,7 +880,6 @@ class DockerEnvironment(BaseEnvironment):
"hermes-agent": "1",
"hermes-task-id": task_label,
"hermes-profile": profile_name,
_EGRESS_LABEL_KEY: egress_label,
}
# Cross-process container reuse (issue #20561 — docs claim "ONE long-lived
@ -1337,15 +889,14 @@ class DockerEnvironment(BaseEnvironment):
# restores the documented contract; opt out via
# ``terminal.docker_persist_across_processes: false``.
#
# Reuse matches on labels only. The egress posture gets its own label
# because env vars, CA mounts, and host mappings are immutable after
# container creation — reusing a pre-egress or pre-rotation container
# would silently bypass the credential firewall.
# Reuse matches on labels only — we deliberately do NOT compare image
# / mounts / resources. Operators who need a fresh container after
# changing those settings should set ``docker_persist_across_processes:
# false`` (or run ``docker rm -f`` against the labeled container) to
# force a clean start.
reused = False
if persist_across_processes:
existing = self._find_reusable_container(
task_label, profile_name, egress_label,
)
existing = self._find_reusable_container(task_label, profile_name)
if existing is not None:
container_id, state = existing
self._container_id = container_id
@ -1518,9 +1069,7 @@ class DockerEnvironment(BaseEnvironment):
# 1. Try label-based reuse (another process may have recreated it).
task_label = self._labels.get("hermes-task-id", "")
profile_label = self._labels.get("hermes-profile", "")
existing = self._find_reusable_container(
task_label, profile_label, self._labels.get(_EGRESS_LABEL_KEY, "off"),
)
existing = self._find_reusable_container(task_label, profile_label)
if existing is not None:
cid, state = existing
if state == "running":
@ -1645,12 +1194,7 @@ class DockerEnvironment(BaseEnvironment):
logger.debug("Docker --storage-opt support: %s", _storage_opt_ok)
return _storage_opt_ok
def _find_reusable_container(
self,
task_label: str,
profile_label: str,
egress_label: str,
) -> Optional[tuple[str, str]]:
def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]:
"""Look for an existing container labeled for this (task, profile).
Returns ``(container_id, state)`` on hit, ``None`` on miss / on any
@ -1664,28 +1208,13 @@ class DockerEnvironment(BaseEnvironment):
started by some other tool.
"""
try:
filters = [
"--filter", "label=hermes-agent=1",
"--filter", f"label=hermes-task-id={task_label}",
"--filter", f"label=hermes-profile={profile_label}",
]
if egress_label != "off":
filters.extend(["--filter", f"label={_EGRESS_LABEL_KEY}={egress_label}"])
fmt = "{{.ID}}\t{{.State}}"
else:
# When egress is off, we widen the probe to find any
# task+profile container (regardless of egress label), then
# post-filter in Python: reject containers whose
# hermes-egress label is present and not "off". Without
# this, a container created with egress=on can be silently
# reused after the operator runs "hermes egress disable",
# preserving baked-in proxy env and CA mounts.
fmt = '{{.ID}}\t{{.State}}\t{{.Label "' + _EGRESS_LABEL_KEY + '"}}'
result = subprocess.run(
[
self._docker_exe, "ps", "-a",
*filters,
"--format", fmt,
"--filter", "label=hermes-agent=1",
"--filter", f"label=hermes-task-id={task_label}",
"--filter", f"label=hermes-profile={profile_label}",
"--format", "{{.ID}}\t{{.State}}",
],
capture_output=True,
text=True,
@ -1702,7 +1231,7 @@ class DockerEnvironment(BaseEnvironment):
result.returncode, result.stderr.strip(),
)
return None
lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
if not lines:
return None
# Multiple matches are unusual (one (task, profile) should produce one
@ -1713,24 +1242,10 @@ class DockerEnvironment(BaseEnvironment):
running = None
first = None
for ln in lines:
if egress_label == "off":
# Format: ID\tState\tEgressLabel — parse all three fields
# and reject containers with a non-off egress label.
parts = ln.split("\t", 2)
if len(parts) < 3:
continue
cid, state, egress_val = parts[0], parts[1].lower(), parts[2]
if egress_val not in ("", "<no value>", "off"):
logger.debug(
"skipping container %s for egress=off reuse: "
"label %s=%r", cid, _EGRESS_LABEL_KEY, egress_val,
)
continue
else:
parts = ln.split("\t", 1)
if len(parts) != 2:
continue
cid, state = parts[0], parts[1].lower()
parts = ln.split("\t", 1)
if len(parts) != 2:
continue
cid, state = parts[0], parts[1].lower()
if first is None:
first = (cid, state)
if state == "running" and running is None:

View file

@ -1,318 +0,0 @@
---
sidebar_position: 14
title: "Egress proxy internals"
description: "How the iron-proxy egress firewall integrates with Hermes — module layout, lifecycle, security invariants, and extension points"
---
# Egress proxy internals
This page covers the architecture of the egress credential-injection firewall (`hermes egress` / iron-proxy) from a contributor / plugin author's perspective. End-user setup + usage docs live at [Egress proxy](../user-guide/egress/iron-proxy.md).
The threat model and high-level design are summarised on the user page; this page is about *how* it's wired, where the security-relevant code lives, and what invariants you have to preserve if you touch it.
## Module layout
```text
agent/proxy_sources/iron_proxy.py Core: binary install, CA gen, config build,
subprocess lifecycle, mappings I/O, PID/nonce
defense. Pure-function surface where possible.
hermes_cli/proxy_cli.py Wizard + slash command handlers.
`hermes egress {install,setup,start,stop,
status,disable,config}`. Wires the
core module into argparse.
hermes_cli/main.py:_dispatch_egress Top-level subparser dispatcher.
dest='egress_command' (intentionally
disjoint from the inbound OAuth
`hermes proxy` subparser, which uses
dest='proxy_command').
hermes_cli/config.py: proxy schema The `proxy:` block in DEFAULT_CONFIG.
Adding a knob means: add it here, add a
wizard prompt or `setdefault` in
proxy_cli.cmd_setup, and document it
in the user-guide page.
tools/environments/docker.py
_egress_proxy_args_for_docker() Builds the volume_args / env_overrides /
host_args triple that the Docker backend
injects when `proxy.enabled: true`.
DockerEnvironment.__init__ Docker-side merge logic: collision
detection against critical egress vars,
NODE_OPTIONS append-merge via the
_HERMES_EGRESS_NODE_OPTIONS_APPEND
sentinel, enforce_on_docker precedence.
tests/test_iron_proxy.py Hermetic tests (~70). Binary install
path, config build, mappings I/O,
subprocess lifecycle, docker arg builder,
deny CIDR defaults, bind policy, CA
TOCTOU, ensure_audit_log behaviour, etc.
tests/test_iron_proxy_cli.py CLI handler unit tests (~20). Argparse
wiring, fail-loud paths, BWS refresh
wire-up, dest='egress_command'
regression guard.
tests/test_iron_proxy_e2e.py Live E2E (gated on HERMES_RUN_E2E=1).
Real iron-proxy binary, real curl,
end-to-end token swap verified.
```
## Lifecycle
```text
hermes egress install
-> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...)
Downloads pinned tarball + checksums.txt from GitHub Releases.
SHA-256 verification before extraction.
tarfile.extract(..., filter="data") on Python 3.12+ (PEP 706);
falls back to plain extract on older Python with member-name
sanitisation via _pick_tar_member.
Stage into ~/.hermes/bin/.iron-proxy_XXXX, chmod 755, os.replace
to ~/.hermes/bin/iron-proxy (atomic).
_VERSION_CACHE.pop(target) so a forced reinstall re-probes
--version on next call.
hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens]
-> proxy_cli.cmd_setup
Step 1. find_iron_proxy(install_if_missing=False) -> install if absent.
Step 2. ensure_ca_cert()
Run openssl genrsa + req via subprocess.
Write CA key via os.open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600)
+ os.replace. Never exists on disk under default umask.
Write CA cert with 0o644 (public).
Step 3. discover_provider_mappings() or pull names from BWS via
fetch_bitwarden_secrets() when --from-bitwarden.
merge_mappings(existing=load_mappings(), discovered,
rotate=args.rotate_tokens) preserves prior
tokens unless --rotate-tokens is passed.
discover_uncovered_providers() and surface warnings.
Step 4. ensure_audit_log(audit_log_path) # raises on OSError
build_proxy_config(...) with defaults applied at the call site
(deny CIDRs default, bind policy from _default_http_listen).
write_proxy_config(cfg) # atomic via .tmp + os.replace, 0o600
write_mappings(mappings) # atomic, 0o600
Step 5. proxy_cfg["enabled"] = True; credential_source preservation logic
(do NOT silently downgrade bitwarden -> env on re-run);
save_config(cfg).
hermes egress start
-> proxy_cli.cmd_start
Pre-checks (refuse-start path):
- credential_source=bitwarden? -> pre-validate access_token_env + project_id
-> iron_proxy.start_proxy(
refresh_secrets_from_bitwarden=...,
bitwarden_config=...,
)
existing=_read_pid(); if alive, idempotent return.
_build_proxy_subprocess_env(...): ALLOWLIST + mapped real_env_names,
strip HTTPS_PROXY/etc. to avoid recursion, optional BWS refresh
(raises on missing values unless allow_env_fallback=true).
Plant nonce: _proxy_nonce = sha256(urandom(16)); env[NONCE_ENV] = ...
Open log_path via O_NOFOLLOW + 0o600 + st_uid check.
Popen with stdin=DEVNULL, stdout=log_fd, stderr=STDOUT,
start_new_session=True (POSIX).
Close parent's log_fd in finally.
_write_pidfile_safely(pidfile, proc.pid)
O_EXCL + O_NOFOLLOW + uid check + persisted nonce sidecar.
FileExistsError -> discriminate live vs stale, retry once if stale.
Install SIGINT/SIGTERM handlers (main-thread only).
Poll loop (do-while shape):
while True:
if proc.poll() is not None: tail log + unlink pidfile + raise
if _port_listening(probe_host, tunnel_port): break # probe_host = configured bind host
if time.time() >= deadline: break (do-while: checked AFTER first probe)
time.sleep(0.1)
If not listening at exit: _kill_and_wait(proc) + unlink pidfile + raise.
hermes egress stop
-> iron_proxy.stop_proxy
_read_pid + _pid_alive guard.
starttime_before = _pid_proc_starttime(pid) # Linux only; None elsewhere
os.kill(pid, SIGTERM)
Wait up to 5s for graceful exit.
After grace: re-check starttime + _pid_alive.
If recycled (starttime drift OR _pid_alive False), DO NOT SIGKILL.
Otherwise os.kill(pid, _KILL_SIGNAL).
_cleanup_state_files: unlink pidfile + nonce sibling.
```
## Security invariants
These are the load-bearing properties. If you touch the module, you must preserve them. Where there's a regression test, it's named.
### Filesystem perms
| Path | Mode | Test |
|---|---|---|
| `~/.hermes/proxy/` (dir) | `0o700` | `test_proxy_state_dir_is_0o700` |
| `ca.key` | `0o600` | `test_ca_key_created_with_0o600` |
| `ca.crt` | `0o644` | (implicit; chmod call in `ensure_ca_cert`) |
| `proxy.yaml` | `0o600` | (chmod after atomic rename in `write_proxy_config`) |
| `mappings.json` | `0o600` | (chmod after atomic rename in `write_mappings`) |
| `iron-proxy.pid` | `0o600` | (`os.open(..., 0o600)` mode in `_write_pidfile_safely`) |
| `iron-proxy.nonce` | `0o600` | (`os.open(..., 0o600)` mode in `_write_pidfile_safely`) |
| `audit.log` | `0o600` | `test_ensure_audit_log_creates_with_0o600` |
| `iron-proxy.log` | `0o600` | (`os.open(..., 0o600)` + `fchmod`) |
All write paths use `os.open(O_WRONLY | O_CREAT | O_NOFOLLOW, 0o600)` + `os.fstat().st_uid` check. `shutil.copy2` + `os.chmod` is forbidden because it leaks a default-umask window.
### Subprocess env minimisation
`_build_proxy_subprocess_env` MUST NOT use `os.environ.copy()`. The allowlist is `_PROXY_SUBPROCESS_ENV_ALLOWLIST` (PATH, HOME, locale, etc.) plus the env names referenced by `load_mappings()`. Everything else stays on the host.
Regression: `test_subprocess_env_strips_unrelated_secrets`, `test_subprocess_env_strips_proxy_recursion_vars`, `test_subprocess_env_keeps_infrastructure_vars`.
### Bind policy
`_default_http_listen` returns a single-element list: on Linux the docker bridge gateway IP (containers reach the proxy via `host.docker.internal:host-gateway`, which resolves to the bridge gateway — a loopback bind is unreachable from inside containers there); on macOS/Windows Docker Desktop, loopback (VPNkit routes `host.docker.internal` to the host). Linux without a detectable docker0 bridge falls back to loopback with a warning. Never `0.0.0.0`, never `:PORT` (INADDR_ANY).
`_detect_docker_bridge_ip` validates via `ipaddress.IPv4Address` and rejects `is_unspecified` / `is_loopback` / `is_multicast` / `is_reserved` / `is_link_local` / `is_global`. A hostile `ip` shim on PATH cannot inject `0.0.0.0`.
**v0.39 schema constraint and listener roles (verified live against the binary):** the binary's `config.Proxy` struct has only singular listener fields — there is no `http_listens` (plural) list. `tunnel_listen` is the CONNECT + MITM listener (what `HTTPS_PROXY` traffic hits); `http_listen` only handles absolute-form plain-HTTP forwards (a CONNECT sent to it is relayed upstream as a regular request and 400s). `build_proxy_config` therefore binds `tunnel_listen` on `tunnel_port` and `http_listen` on `tunnel_port + 1`, both on the platform bind host. The Docker backend sets `HTTPS_PROXY` to `tunnel_port` and `HTTP_PROXY` to `tunnel_port + 1`.
The liveness probes (`start_proxy` poll loop, `get_status`) read the configured bind host via `_read_http_listen_from_config()` and probe THAT host — a hardcoded loopback probe would report a healthy bridge-bound daemon as dead.
Regression: `test_default_bind_is_loopback_not_zero_zero` (asserts no INADDR_ANY AND that `http_listens` is NOT in the rendered yaml), `test_default_bind_uses_docker_bridge_on_linux`, `test_default_bind_falls_back_to_loopback_without_bridge`, `test_default_bind_is_loopback_on_macos`, `test_detect_docker_bridge_ip_rejects_dangerous` (parametrized over 8 attack inputs).
### Metrics port collision
`metrics.listen` defaults to `:9090` in iron-proxy v0.39 — the SAME port as Hermes's default `tunnel_port: 9090`. `build_proxy_config` MUST explicitly pin `metrics.listen: 127.0.0.1:0` so the metrics binding gets an ephemeral loopback port that can never collide with the proxy listener regardless of operator-chosen `tunnel_port`.
Regression: `test_metrics_listener_pinned_to_loopback_ephemeral`.
### Default deny CIDRs
`_DEFAULT_UPSTREAM_DENY_CIDRS` covers loopback (v4 + v6), link-local (incl. IMDS at 169.254.169.254 and the IPv4-mapped-v6 form), RFC1918, IPv6 ULA, CGNAT, and the RFC2544 benchmark range. `build_proxy_config(..., upstream_deny_cidrs=None)` MUST emit the default; only an explicit empty list opts out.
Regression: `test_default_deny_cidrs_present_when_unspecified`, `test_default_deny_includes_ipv4_mapped_v6`.
### Audit log fail-loud
`ensure_audit_log` raises `RuntimeError` on any `OSError`. On the pinned v0.39 the daemon never writes this file (no `log.audit_path` field), so `cmd_setup` treats the failure as a WARNING (the file is non-load-bearing until the version bump) and qualifies the success line as "reserved". When the pin moves to a version with `log.audit_path`, revisit: the pre-create becomes load-bearing for the 0o600-from-first-byte guarantee and the wizard should fail loud again.
**v0.39 schema constraint:** `log.audit_path` is NOT a field in iron-proxy v0.39's `config.Log` struct, so `build_proxy_config` accepts the `audit_log` kwarg but does NOT emit it into the rendered yaml. Per-request records on v0.39 land in `iron-proxy.log` alongside daemon-level events. The `audit.log` file is still pre-created at `0o600` with `O_NOFOLLOW` so the privacy contract holds when the pinned version is bumped to one that supports the separate stream.
Regression: `test_ensure_audit_log_raises_on_immutable_parent`, `test_audit_log_kwarg_does_not_inject_audit_path_v039`.
### Bitwarden mode fail-loud
When `credential_source: bitwarden` AND `proxy.allow_env_fallback: false` (default):
- Missing access token env var -> `cmd_start` refuses.
- Missing `project_id` -> `cmd_start` refuses.
- `bws secret list` returns no values for one or more mapped providers -> `_build_proxy_subprocess_env` raises.
Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat.
Regression: `test_cmd_start_refuses_when_bitwarden_token_missing` (CLI layer); strict-mode assertions in `_build_proxy_subprocess_env` (daemon layer).
### docker_env collision detection
When `enforce_on_docker: true`, `docker_env` overrides on any of the egress-controlling vars (HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, etc.) OR any mapped `real_env_name` (OPENROUTER_API_KEY, etc.) raises `RuntimeError` BEFORE the container starts.
Regression: `test_docker_env_collision_with_proxy_raises_when_enforce`.
### PID recycling defense
`_pid_alive` MUST consult either the in-process `_proxy_nonce` (same-process case) OR the on-disk `iron-proxy.nonce` (cross-CLI case) before trusting an `argv[0]` basename match. `stop_proxy` MUST re-check `/proc/<pid>/stat` starttime before SIGKILL and suppress the signal on starttime drift.
Regression: `test_stop_proxy_suppresses_sigkill_on_pid_recycle`, `test_pid_proc_starttime_parses_comm_with_parens`, `test_persisted_nonce_roundtrip`.
### Token preservation on re-setup
`merge_mappings(existing, discovered, rotate=False)` MUST return prior tokens for providers that overlap. Re-running `hermes egress setup` cannot silently 401 running sandboxes. `--rotate-tokens` is the explicit opt-in.
Regression: `test_merge_mappings_preserves_existing_tokens`, `test_merge_mappings_rotate_mints_fresh_tokens`.
### `credential_source` preservation
`cmd_setup` MUST NOT downgrade `credential_source: bitwarden` to `env` on re-run without an explicit `--no-bitwarden` flag. Running `hermes egress setup` (no flag) preserves whatever was previously configured.
Tested via the `cmd_setup` flow in CLI tests (the bitwarden-preservation path is exercised when `--from-bitwarden` is followed by a plain `setup` re-run).
## Extension points
### Adding a new bearer-token provider
`_BEARER_PROVIDERS` in `iron_proxy.py` maps env var name -> tuple of upstream hosts. Adding an entry makes it discoverable by `discover_provider_mappings()`; the wizard mints a token for it automatically when the env var is present.
```python
_BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = {
...,
"MY_PROVIDER_API_KEY": ("api.myprovider.com",),
}
```
Also update `_DEFAULT_ALLOWED_HOSTS` so the proxy allows the upstream by default. Run `test_discover_provider_mappings_*` to confirm.
### Adding a new header-token provider (x-api-key family)
If the provider authenticates with a static NON-Authorization header (like Anthropic's `x-api-key`, Azure's `api-key`, or Gemini's `x-goog-api-key`), add it to `_HEADER_AUTH_PROVIDERS` — iron-proxy's `secrets.replace.match_headers` targets arbitrary header names, so these are first-class swapped providers:
```python
_HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = {
...,
"MY_PROVIDER_API_KEY": {
"hosts": ("api.myprovider.com",),
"match_headers": ("x-my-auth-header", "Authorization"),
"aliases": (),
},
}
```
Use `aliases` ONLY for interchangeable env-var names of the *same* credential (e.g. `GOOGLE_API_KEY` for `GEMINI_API_KEY`) — aliased names collapse into a single mapping, because two `require: true` rules on the same host reject each other's requests. Also update `_DEFAULT_ALLOWED_HOSTS`.
### Adding a new signature-auth provider (uncovered)
If the provider uses SigV4 / SDK-minted OAuth / request signatures, a static header swap cannot cover it. Add the env var to `_NON_BEARER_PROVIDERS` so the wizard and `hermes egress status` warn about it:
```python
_NON_BEARER_PROVIDERS: Tuple[str, ...] = (
...,
"MY_SIGNED_PROVIDER_ACCESS_KEY",
)
```
### Wiring iron-proxy into a non-Docker backend
`_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.
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.
5. Distributes the CA cert into the sandbox at a path the runtime will trust (typically `/etc/ssl/certs/hermes-egress-ca.crt`).
6. Implements collision detection against the user's backend-specific env config.
The Docker implementation is ~150 lines; expect similar volume for Modal / Daytona / SSH.
### Subscribing to per-request audit events
iron-proxy writes line-delimited JSON to `~/.hermes/proxy/iron-proxy.log` on the currently pinned v0.39 (daemon + per-request records combined; see "Logging on iron-proxy v0.39" in the user guide). A plugin / external watcher can tail that file and react to allowlist denials, secret swaps, or upstream errors. When the pinned version is bumped to one that supports `log.audit_path`, the per-request stream moves to `audit.log` and watchers wired to that path go live without operator action. The schema is documented at [docs.iron.sh/audit](https://docs.iron.sh/audit) (link).
## Testing
```bash
# Hermetic suite (no network, no real binary)
scripts/run_tests.sh tests/test_iron_proxy.py tests/test_iron_proxy_cli.py
# Live E2E (real binary, real curl, real CONNECT tunnel)
HERMES_RUN_E2E=1 scripts/run_tests.sh tests/test_iron_proxy_e2e.py
# Live PTY smoke against `hermes egress`
HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress --help
HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress setup --help
```
The CLI uses argparse, so `--help` is a good first probe for "did my new flag register correctly".
## See also
- User-facing setup + troubleshooting: [Egress proxy](https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy)
- Docker backend internals: [Docker](https://hermes-agent.nousresearch.com/docs/user-guide/docker)
- Bitwarden Secrets Manager integration: [`hermes secrets bitwarden`](https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden)
- CLI command reference: [`hermes egress`](https://hermes-agent.nousresearch.com/docs/reference/cli-commands#hermes-egress)
- Sandbox-injected environment variables: [Egress proxy (sandbox-injected)](https://hermes-agent.nousresearch.com/docs/reference/environment-variables#egress-proxy-sandbox-injected)

View file

@ -273,8 +273,6 @@ hermes config set terminal.backend docker # Docker isolation
hermes config set terminal.backend ssh # Remote server
```
For Docker sandboxes, you can also enable the **egress credential-injection proxy** so the sandbox never sees your real API keys — only opaque proxy tokens that work exclusively from behind a local TLS-intercepting daemon. See [Egress proxy](../user-guide/egress/iron-proxy.md). Setup is `hermes egress setup && hermes egress start`; `hermes setup terminal` also points Docker users at it. Modal, SSH, Daytona, and Singularity are not wired yet.
### Voice mode
```bash

View file

@ -43,7 +43,6 @@ hermes [global-options] <command> [subcommand/options]
| `hermes fallback` | Manage fallback providers tried when the primary model errors. |
| `hermes gateway` | Run or manage the messaging gateway service. |
| `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). |
| `hermes egress` | Outbound credential-injection firewall for remote terminal sandboxes (iron-proxy). Disabled by default. See [Egress proxy](../user-guide/egress/iron-proxy.md). |
| `hermes lsp` | Manage Language Server Protocol integration (semantic diagnostics for write_file/patch). |
| `hermes setup` | Interactive setup wizard for all or part of the configuration. |
| `hermes whatsapp` | Configure and pair the WhatsApp bridge. |
@ -619,67 +618,6 @@ All actions are also available as a slash command in the gateway (`/kanban …`)
For the full design — comparison with Cline Kanban / Paperclip / NanoClaw / Gemini Enterprise, eight collaboration patterns, four user stories, concurrency correctness proof — see `docs/hermes-kanban-v1-spec.pdf` in the repository or the [Kanban user guide](/user-guide/features/kanban).
## `hermes egress`
Outbound credential-injection firewall for remote terminal sandboxes. Wraps the [iron-proxy](https://github.com/ironsh/iron-proxy) daemon — a TLS-intercepting proxy that swaps opaque proxy tokens for real upstream API credentials at the network boundary, so sandboxes never hold real keys. Disabled by default; see the full [Egress proxy](../user-guide/egress/iron-proxy.md) page for setup + architecture.
```bash
hermes egress install # download the pinned iron-proxy binary
hermes egress install --force # re-download even if already installed
hermes egress setup # interactive wizard: CA, mappings, config
hermes egress setup --tunnel-port N # override the tunnel listener port (default 9090)
hermes egress setup --from-bitwarden # use Bitwarden Secrets Manager as credential source
hermes egress setup --no-bitwarden # explicitly switch back to env-based credentials
hermes egress setup --rotate-tokens # mint fresh proxy tokens (default preserves existing)
hermes egress start # spawn the managed proxy daemon
hermes egress stop # SIGTERM (then SIGKILL after 5s grace)
hermes egress restart # stop (if running) then start — needed for secret changes
hermes egress reload # hot-reload the ruleset in-place (no restart, no dropped
# connections) via the loopback management API
hermes egress status # binary + config + pid + listening + mappings
hermes egress status --show-tokens # print proxy tokens in full (default: redacted)
hermes egress disable # flip proxy.enabled = false (does not stop a running proxy)
hermes egress config # print the path to proxy.yaml for inspection
```
### Common flows
```bash
# First-time setup
export OPENROUTER_API_KEY=…
hermes egress setup && hermes egress start
hermes config set terminal.backend docker # if not already
# Switching credential source after the fact
hermes egress setup --from-bitwarden # env → bitwarden
hermes egress setup --no-bitwarden # bitwarden → env
# (just `setup` without either flag preserves the existing mode)
# Rotating all tokens (e.g. after a suspected token leak)
hermes egress setup --rotate-tokens # setup offers to restart the running daemon for you
# (running sandboxes still hold old tokens; restart them too)
# Adding a new upstream
# Edit ~/.hermes/config.yaml proxy.extra_allowed_hosts: [api.example.com]
hermes egress setup
hermes egress restart # one-command apply (stop + start)
```
### Diagnostic shortcuts
```bash
hermes egress status # current state in one view
cat ~/.hermes/proxy/proxy.yaml # the rendered iron-proxy config
tail -20 ~/.hermes/proxy/iron-proxy.log # daemon-level diagnostics
tail -f ~/.hermes/proxy/iron-proxy.log | jq # daemon + per-request log (line-delimited JSON; v0.39 combines both streams)
```
Common failure modes + recovery are covered in [Egress proxy → Troubleshooting](../user-guide/egress/iron-proxy.md#troubleshooting).
## `hermes project`
```bash

View file

@ -248,23 +248,6 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `TERMINAL_LOCAL_PERSISTENT` | Enable persistent shell for local backend (default: `false`) |
| `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH backend (default: follows `TERMINAL_PERSISTENT_SHELL`) |
## Egress proxy (sandbox-injected)
These env vars are NOT set on the host — they're injected into Docker sandboxes by the [Egress proxy](../user-guide/egress/iron-proxy.md) integration when `proxy.enabled: true`. Docker is the only wired backend in this release.
| Variable | Description |
|----------|-------------|
| `HERMES_EGRESS_PROXY` | Set to `1` inside a sandbox when the egress proxy is active. Agent code can check this to know it's running behind a TLS-intercepting proxy. |
| Provider env vars (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, …) | Set to opaque proxy tokens, not real upstream secrets, so existing SDKs keep reading the standard env names. iron-proxy swaps those tokens for the real upstream secret at the network boundary. |
| `HERMES_PROXY_TOKEN_<ENV_NAME>` | Diagnostic alias for each minted provider mapping. E.g. `HERMES_PROXY_TOKEN_OPENROUTER_API_KEY=hermes-proxy-openrouter-…`. Same token value as the standard provider env var. |
| `HTTPS_PROXY` / `HTTP_PROXY` | `HTTPS_PROXY` points at `http://host.docker.internal:<tunnel_port>` for CONNECT/MITM. `HTTP_PROXY` points at `<tunnel_port + 1>` for plain-HTTP forwarding. |
| `NO_PROXY` | `127.0.0.1,localhost,::1` so loopback dev servers inside the sandbox bypass the proxy. |
| `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` | Path to the mounted Hermes egress CA cert inside the sandbox (`/etc/ssl/certs/hermes-egress-ca.crt`). Lets the language runtimes trust iron-proxy's MITM-minted leaf certs. |
| `NODE_OPTIONS` | Appended with `--use-openssl-ca` (your existing flags are preserved) so Node.js routes through the OpenSSL store the other CA-bundle vars control. Narrows the [Node.js asymmetric CA caveat](../user-guide/egress/iron-proxy.md#nodejs-asymmetric-ca-caveat). |
| `HERMES_IRON_PROXY_NONCE` | Set on the iron-proxy daemon process itself (NOT inside the sandbox). Used by `_pid_alive` to confirm a candidate PID still refers to *our* managed binary across PID recycling. |
These are set automatically by the Docker terminal backend when `proxy.enabled: true` AND the daemon is running. You don't set them yourself; the relevant operator-facing knobs are in `~/.hermes/config.yaml` under the `proxy:` section — see [Egress proxy → Configuration](../user-guide/egress/iron-proxy.md#configuration).
## Messaging
| Variable | Description |

View file

@ -55,7 +55,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/moa <prompt>` | Run a single prompt through the default [Mixture of Agents](/user-guide/features/mixture-of-agents) preset, then restore your current model. One-shot — does not change your session model. |
| `/resume [name]` | Resume a previously-named session |
| `/sessions` (TUI alias: `/switch`) | Classic CLI: browse and resume previous sessions in an interactive picker. TUI: open the live session switcher for currently open TUI sessions. Use `/sessions new` in the TUI to start another live session immediately. |
| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. |
| `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) |
| `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. |
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
@ -255,7 +254,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume <id-or-title>` for saved or closed transcripts.

View file

@ -1,10 +0,0 @@
---
title: Egress proxy
sidebar_position: 1
---
# Egress proxy
Optional outbound credential-injection firewall for remote terminal sandboxes. The sandbox only ever holds opaque proxy tokens; real API keys never leave the host.
- [iron-proxy](./iron-proxy) — single-binary TLS-intercepting proxy from [ironsh/iron-proxy](https://github.com/ironsh/iron-proxy), lazy-installed and managed by `hermes egress`.

View file

@ -1,570 +0,0 @@
# Egress credential-injection proxy (iron-proxy)
When Hermes runs your agent inside a Docker terminal sandbox, that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them.
The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work behind the **configured trusted proxy boundary** — the CA private key and the proxy endpoint integrity are part of that boundary. If traffic can be redirected to attacker-controlled proxy infrastructure (e.g. a stolen CA private key or a hijacked proxy endpoint), the token guarantee no longer holds.
This release wires the egress proxy into the Docker backend only. Modal, Daytona, SSH, and Singularity do **not** receive proxy env vars or CA mounts yet.
## What it is
- A managed `iron-proxy` subprocess on the host, lazy-installed into `~/.hermes/bin/iron-proxy`
- A local CA at `~/.hermes/proxy/ca.crt` that the sandbox trusts so iron-proxy can MITM TLS and rewrite headers
- A `proxy.yaml` config at `~/.hermes/proxy/proxy.yaml` listing the upstream hosts you allow and the secrets-transform mapping
- A `mappings.json` recording which proxy token corresponds to which real env var
The sandbox gets `HTTPS_PROXY=http://host.docker.internal:9090`, `HTTP_PROXY=http://host.docker.internal:9091`, and standard provider env vars such as `OPENROUTER_API_KEY` set to opaque proxy tokens. Matching `HERMES_PROXY_TOKEN_<ENV_NAME>` aliases are also exported for diagnostics. Existing provider SDKs read the usual env names, send the proxy token in `Authorization`, and iron-proxy's `secrets` transform substitutes the real value sourced from the host-side daemon environment.
## What it is not
- It is **not** the inbound `hermes proxy` command, which is an OAuth aggregator reverse proxy. Different command (`hermes egress`), different direction.
- It does **not** sit between your local terminal and providers — only between the sandbox and providers.
- It does **not** rewrite credentials for in-process LLM calls the host process makes. Those continue to use your `.env` keys directly. The threat model is the *sandbox*, not the host.
## Quick start
```bash
# 1. Install the iron-proxy binary (pinned version, SHA-256 verified)
hermes egress install
# 2. Run the wizard: generates CA, mints proxy tokens for every provider key
# in your env, writes proxy.yaml.
hermes egress setup
# 3. Start the proxy daemon
hermes egress start
# 4. Check status
hermes egress status
```
`hermes egress setup` discovers provider keys from your environment. If your keys live only in `~/.hermes/.env` (not exported into your shell), setup reads that file automatically — you don't have to `export` them first.
When you re-run `setup` later (new allowlist host, rotated tokens, switched credential source), it stops the running daemon because its config is held in memory, then **offers to restart it for you** so the change takes effect immediately. On a tty it asks; pass `--restart` to always restart or `--no-restart` to leave it down. To apply changes any other time, `hermes egress restart` is the one-command stop-then-start.
Once running, the Docker terminal backend automatically:
- Mounts `~/.hermes/proxy/ca.crt` into the sandbox at `/etc/ssl/certs/hermes-egress-ca.crt`
- Sets `HTTPS_PROXY`, `HTTP_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` to make every common HTTP runtime route through the proxy and trust the CA
- Sets `NODE_OPTIONS=--use-openssl-ca` (appended to whatever you already have in `docker_env.NODE_OPTIONS`) so Node.js routes through the OpenSSL store the other CA-bundle vars control — see [Node.js asymmetric CA caveat](#nodejs-asymmetric-ca-caveat) below for the residual gap
- Adds `--add-host=host.docker.internal:host-gateway` so the sandbox can reach the host-side proxy on Linux (Docker Desktop handles this automatically on macOS/Windows)
- Exports the proxy token under the standard provider env name (for example `OPENROUTER_API_KEY`) plus one `HERMES_PROXY_TOKEN_<ENV_NAME>` diagnostic alias per minted mapping
## Configuration
The full config lives in `~/.hermes/config.yaml` under the `proxy:` section. Defaults are documented inline; everything is optional.
```yaml
proxy:
# Master switch. When false the feature is a complete no-op — no
# binaries downloaded, no docker mounts added, no subprocess started.
enabled: false
# Tunnel listener port. Sandboxes hit http://host.docker.internal:<port>.
tunnel_port: 9090
# Auto-download the pinned iron-proxy binary on first use.
auto_install: true
# Where iron-proxy looks up the real upstream secrets at egress time.
# env — process env (default). Whatever is in your ~/.hermes/.env
# at proxy-start time is the source of truth.
# bitwarden — refetch from Bitwarden Secrets Manager on each proxy
# restart. Rotation in the BW web app propagates without
# touching .env. Requires `secrets.bitwarden.enabled: true`.
credential_source: env
# When true (default), the Docker backend refuses to start a sandbox if
# the proxy is enabled but not running. Set to false to fall back to the
# legacy "real credentials inside the sandbox" posture when the proxy
# is unavailable.
enforce_on_docker: true
# When `credential_source: bitwarden` but the BWS access token /
# project_id is missing OR the bws fetch returns no values for mapped
# providers, the daemon raises by default (matches the spirit of "I
# asked for rotation — don't silently use stale env values"). Set
# to true to opt back into the legacy host-env fallback — useful for
# migrations where you want to start switching to BW mode but haven't
# wired every secret yet.
allow_env_fallback: false
# SSRF deny list applied to outbound traffic. Omit / leave null to
# use the safe default: loopback (v4 + v6), link-local (incl. cloud
# metadata IPs at 169.254.169.254), RFC1918, IPv6 ULA, IPv4-mapped-v6,
# CGNAT, and the RFC2544 benchmark range. Set to an explicit `[]`
# to opt out entirely (only sensible in hermetic tests).
upstream_deny_cidrs: null
# Extra allowed upstream hosts beyond the bundled defaults.
# Wildcards (`*.foo.com`) are supported. The defaults cover OpenRouter,
# OpenAI, Anthropic, Google, xAI, Mistral, Groq, Together, DeepSeek,
# and Nous Research.
extra_allowed_hosts: []
```
### Default allowed upstream hosts
```
openrouter.ai *.openrouter.ai
api.openai.com api.anthropic.com
generativelanguage.googleapis.com
api.x.ai api.mistral.ai
api.groq.com api.together.xyz
api.deepseek.com inference.nousresearch.com
```
If your agent needs an upstream that isn't on the list — a self-hosted inference endpoint, an extra cloud LLM, an MCP server — add it to `proxy.extra_allowed_hosts`. Wildcards are matched against the full hostname (`*.example.com` matches `api.example.com` and `staging.example.com` but not `example.com` itself).
### Default SSRF deny CIDRs
Applied regardless of allowlist. These ranges are refused by iron-proxy at the network boundary, so a DNS rebinding attack via an allowlisted hostname can't reach IMDS or your internal network:
| CIDR | Purpose |
|---|---|
| `127.0.0.0/8`, `::1/128` | Loopback (v4 + v6) |
| `169.254.0.0/16`, `fe80::/10` | Link-local — **incl. AWS / GCP / Azure IMDS at `169.254.169.254`** |
| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | RFC1918 |
| `fc00::/7` | IPv6 ULA |
| `::ffff:0:0/96` | IPv4-mapped IPv6 — closes the dual-stack IMDS bypass |
| `100.64.0.0/10` | RFC6598 CGNAT (used by AWS VPC, K8s pod networks) |
| `198.18.0.0/15` | RFC2544 benchmark range |
To override: set `proxy.upstream_deny_cidrs` to your own list. To opt out entirely (e.g. for a hermetic test that needs to reach a loopback upstream): set it to an empty list `[]`.
### Bind policy
The proxy never binds `0.0.0.0`. The default bind is platform-specific because iron-proxy v0.39 supports only a **single bind per daemon process**:
- **Linux:** the docker bridge gateway (`172.17.0.1:<tunnel_port>` by default). Containers reach the proxy via `host.docker.internal`, which `--add-host=host.docker.internal:host-gateway` resolves to exactly this bridge gateway IP — a loopback-only bind would be unreachable from inside sandboxes. The bridge IP is an address on the host's `docker0` interface, so it is not exposed to the LAN; it IS reachable by other containers on the default bridge network, but requests still require a minted proxy token and an allowlisted upstream. If no docker bridge is detected (docker not installed/running), the bind falls back to loopback with a warning.
- **macOS / Windows Docker Desktop:** loopback (`127.0.0.1:<tunnel_port>`). Desktop's VPNkit routes `host.docker.internal` to the host, so loopback is reachable from containers and is the least-exposed choice.
A LAN peer with a leaked proxy token cannot use the proxy — neither bind is reachable from the external network.
We also pin `metrics.listen: 127.0.0.1:0` so the daemon's built-in metrics server gets an ephemeral loopback port instead of its default `:9090` — otherwise it would fight `tunnel_port: 9090` for the same socket and the daemon would refuse to start with "address already in use". Note the `:0` ephemeral port is random per start and not surfaced anywhere, so metrics are effectively disabled at this pin.
If a hostile `ip` shim earlier on PATH had been able to inject a non-private IPv4 as the bridge address (`0.0.0.0`, a public address, multicast, link-local, etc.) the loopback fallback still applies — we never bind anything we couldn't validate via `ipaddress.IPv4Address` + `is_*` checks.
## Covered auth schemes
The `secrets` transform swaps the proxy token wherever it appears in a matched location — and it matches more than `Authorization: Bearer`:
| Provider | Env var | Swapped in |
|---|---|---|
| OpenRouter, OpenAI, Groq, Together, DeepSeek, Mistral, xAI, Nous | `*_API_KEY` | `Authorization` header |
| Anthropic native | `ANTHROPIC_API_KEY` | `x-api-key` + `Authorization` |
| Azure OpenAI | `AZURE_OPENAI_API_KEY` | `api-key` + `Authorization` (`*.openai.azure.com`, `*.cognitiveservices.azure.com`, `*.services.ai.azure.com`) |
| Google AI Studio (Gemini) | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | `x-goog-api-key` header or `?key=` query param |
`GEMINI_API_KEY` and `GOOGLE_API_KEY` are treated as one credential: a single proxy token is minted and injected into the sandbox under **both** names, and either name in your host env satisfies discovery.
## Uncovered providers
Auth schemes that involve request signing or SDK-minted OAuth cannot be swapped by a static header replacement — if their env vars are present, the sandbox holds **real credentials** for those providers and the egress isolation guarantee is incomplete for them:
| Env var | Provider | Reason |
|---|---|---|
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | AWS Bedrock / SageMaker | SigV4-signed requests |
| `GOOGLE_APPLICATION_CREDENTIALS` | GCP Vertex AI | OAuth minted from a service-account file |
These env vars are present on most developer laptops for unrelated tooling (terraform, gcloud, aws CLI, ECR push). They surface as warnings in the wizard and `hermes egress status` but never block the proxy from starting. If you don't use those providers from sandboxes, `unset` the vars to clear the warning.
## Bitwarden integration
If you already use Bitwarden Secrets Manager via [`hermes secrets bitwarden setup`](../secrets/bitwarden), the egress proxy can pull real credentials from there instead of `os.environ`:
```bash
hermes egress setup --from-bitwarden
```
This sets `proxy.credential_source: bitwarden` and discovers provider env names from your BW project.
### Rotation semantics
When `credential_source: bitwarden`, the iron-proxy daemon refetches secrets from BWS via `bws secret list <project_id>` **every time it starts**. So the rotation flow is:
1. Rotate a key in the Bitwarden web app.
2. `hermes egress stop && hermes egress start` on the host.
3. Sandboxes started after that point swap proxy tokens for the new value.
No `.env` edits. No Hermes restart on the host. The proxy daemon is the only thing that touches the new value — your host process and `os.environ` are untouched.
### Fail-loud at start
When `credential_source: bitwarden`, `hermes egress start` pre-checks at the wizard layer AND `_build_proxy_subprocess_env` re-checks at the daemon layer:
- BWS access token env var is unset → refuse to start with a hint to `unset` and re-run, or `hermes egress setup --no-bitwarden` to switch back to env mode
- `secrets.bitwarden.project_id` is empty → refuse to start with a hint to run `hermes secrets bitwarden setup`
- `bws secret list` returns no values for one or more mapped providers → refuse to start, listing the missing names
This is intentional. Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat (operator picked BW for the rotation guarantee; silent fallback breaks that guarantee).
The `proxy.allow_env_fallback: true` config flag opts back in to the legacy "silently fall back to host env if BWS is unreachable" behavior for migration scenarios. Use it when you're moving secrets into BW one at a time and want the daemon to start with whichever values are available.
### Switching credential source
| From | To | Command |
|---|---|---|
| env | bitwarden | `hermes egress setup --from-bitwarden` |
| bitwarden | env | `hermes egress setup --no-bitwarden` |
**Re-running `hermes egress setup` WITHOUT either flag preserves the existing `credential_source`** — the wizard refuses to silently downgrade you back to env. This matters because once you've configured bitwarden mode, the rotation guarantee is what you signed up for; you have to explicitly say "I want env again" to change it.
## Slash commands
The CLI subcommand tree:
```
hermes egress install # download the pinned iron-proxy binary
hermes egress install --force # re-download even if a managed copy exists
hermes egress setup # interactive wizard
hermes egress setup --tunnel-port N # override the tunnel listener port
hermes egress setup --from-bitwarden # use BWS as credential source (fail-loud)
hermes egress setup --no-bitwarden # explicitly switch back to env mode
hermes egress setup --rotate-tokens # mint fresh tokens for every provider
# (default preserves existing)
hermes egress start # spawn the managed proxy daemon
hermes egress stop # SIGTERM (then SIGKILL after 5s grace)
hermes egress restart # stop (if running) then start — needed when
# upstream SECRETS change (rotation, new provider)
hermes egress reload # hot-reload the ruleset from proxy.yaml via the
# management API — no restart, no dropped
# connections (allowlist / mapping edits)
hermes egress status # binary + config + pid + listening state + mappings
hermes egress status --show-tokens # print proxy tokens in full
# (default: redacted prefix + suffix only)
hermes egress disable # flip proxy.enabled = false
# (does not stop a running proxy)
hermes egress config # print the path to proxy.yaml for debugging
```
### Token rotation
By default, `hermes egress setup` **preserves** proxy tokens for providers that already have them. Adding a new provider mints a fresh token only for the new one; existing tokens are unchanged. This avoids 401-ing running sandboxes when you re-run the wizard.
`--rotate-tokens` rolls every token:
```bash
hermes egress setup --rotate-tokens
```
When there are existing tokens AND stdin is a tty, the wizard prompts for confirmation:
```
⚠ --rotate-tokens will invalidate proxy tokens in every running
Hermes sandbox. They will start 401-ing against upstreams until restarted.
Type 'rotate' to confirm:
```
Non-tty invocations (CI, scripts) skip the prompt — the flag is treated as deliberate. Before any overwrite the current `mappings.json` is copied to a timestamped sibling so manual recovery is possible:
```
backup: ~/.hermes/proxy/mappings.json.rotated-20260524T143012
```
`hermes egress setup` stops a running daemon when it rewrites config or token mappings, because the daemon keeps the old YAML in memory. After `--rotate-tokens`:
```bash
hermes egress start
```
Containers already running hold the old tokens and will need to be restarted to pick up the new ones. New persistent Docker containers include an egress-posture label, so Hermes will not reuse a pre-egress or pre-rotation container for new sessions.
## State directory layout
Everything iron-proxy maintains lives in `~/.hermes/proxy/`:
| Path | Mode | Purpose |
|---|---|---|
| `~/.hermes/proxy/` (dir) | `0o700` | Owned + traversable by you only |
| `ca.crt` | `0o644` | Public CA cert distributed into sandboxes |
| `ca.key` | `0o600` | CA signing key — never leaves the host |
| `proxy.yaml` | `0o600` | iron-proxy config; rewritten every `setup` |
| `mappings.json` | `0o600` | Sandbox proxy token → upstream env var |
| `mappings.json.rotated-*` | `0o600` | Backups created by `--rotate-tokens` |
| `iron-proxy.pid` | `0o600` | PID of the running daemon |
| `iron-proxy.nonce` | `0o600` | Per-start nonce for PID-recycle defense |
| `iron-proxy.log` | `0o600` | Daemon stdout/stderr — **includes per-request records on v0.39** |
| `audit.log` | `0o600` | Reserved for the dedicated per-request audit stream on future binary versions; pre-created so the privacy contract holds when upstream wires it in |
The CA private key is the most sensitive file. It's created with `0o600` from the first byte (no umask-window TOCTOU) and `O_NOFOLLOW` so a same-uid attacker can't redirect it via a planted symlink. The pidfile, nonce file, daemon log, and audit log get the same treatment.
### Logging on iron-proxy v0.39
On the currently pinned binary version (**v0.39.0**) iron-proxy writes ALL output — daemon-level diagnostics AND per-request records — to **`~/.hermes/proxy/iron-proxy.log`**. v0.39's `config.Log` struct doesn't have a separate `audit_path` field, so we can't route per-request records to a dedicated stream there.
We still pre-create `~/.hermes/proxy/audit.log` at `0o600` with `O_NOFOLLOW` because:
1. It reserves the path for the future version bump: when the pinned version moves to one that supports `log.audit_path`, per-request records will start flowing there without operator-side reconfiguration. **Until then the file stays at 0 bytes — do not point monitoring, alerting, or forensics tooling at it yet.** Use `iron-proxy.log` for everything today.
2. The 0o600-from-first-byte guarantee defends against the upstream-fix-day where v0.40+ creates the file under its default umask if it doesn't already exist.
Until that version bump lands, treat `iron-proxy.log` as the source of truth for both audiences:
- Daemon-level events (startup banner, bind errors, shutdown reason, transform errors). Operations + troubleshooting.
- Per-request records (CONNECT to allowlisted upstream, secret swap fired, allowlist denial). Forensics + compliance.
Both files are appended to across restarts. Rotate them with logrotate if you care about disk usage on long-lived hosts.
## How it works
```
┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ Docker │ CONNECT / │ iron-proxy │ HTTPS w/ │ OpenRouter │
│ sandbox ├──────────────▶│ (host:9090) ├───────────────▶│ / OpenAI / │
│ │ HTTP forward │ │ real API key │ Anthropic … │
│ has: │ w/ proxy tok │ mints leaf │ │ │
│ - proxy tok │ in Auth hdr │ cert from CA │ │ │
│ - CA cert │ │ matches token │ │ │
│ - HTTPS_PROXY│ │ swaps secret │ │ │
└──────────────┘ └──────────────┘ └─────────────┘
│ daemon + per-request log (combined on v0.39)
~/.hermes/proxy/iron-proxy.log
(~/.hermes/proxy/audit.log reserved for v0.40+ split stream)
```
1. Sandbox makes an HTTPS request, e.g. `POST https://openrouter.ai/v1/chat/completions` with `Authorization: Bearer hermes-proxy-openrouter-…` (the proxy token, not the real key).
2. Because `HTTPS_PROXY` is set, the request goes to iron-proxy as a CONNECT tunnel.
3. iron-proxy checks the allowlist. `openrouter.ai` is allowed.
4. iron-proxy mints a leaf cert signed by our CA for `openrouter.ai`, terminates the TLS connection, inspects the request.
5. The `secrets` transform matches the proxy-token string in the `Authorization` header and substitutes the real `OPENROUTER_API_KEY` value, sourced from iron-proxy's own environment.
6. Request is re-encrypted and forwarded to OpenRouter.
7. The request is logged to `~/.hermes/proxy/iron-proxy.log` on v0.39. When the pinned binary version supports the split stream (v0.40+), per-request records will flow to `~/.hermes/proxy/audit.log` and daemon-level diagnostics will stay in `iron-proxy.log`. See [Logging on iron-proxy v0.39](#logging-on-iron-proxy-v039).
A request to a non-allowlisted host (e.g. `https://attacker.example.com/leak?key=...`) is rejected with HTTP 403 before any bytes leave the host. The denial is recorded in `iron-proxy.log` with the upstream host and the source sandbox.
### CA distribution into the sandbox
When the Docker backend starts a container with `proxy.enabled: true` and the daemon is listening, it adds these arguments to `docker run`:
| Arg | Purpose |
|---|---|
| `-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro` | Read-only mount of the CA |
| `-e HTTPS_PROXY=http://host.docker.internal:9090` | Python httpx / curl / go default transport / Node fetch |
| `-e HTTP_PROXY=http://host.docker.internal:9091` | curl + wget for plain HTTP — the plain-HTTP forward listener lives on `tunnel_port + 1` |
| `-e NO_PROXY=127.0.0.1,localhost,::1` | Loopback dev servers inside the sandbox bypass the proxy |
| `-e REQUESTS_CA_BUNDLE=…ca.crt` | Python `requests` |
| `-e SSL_CERT_FILE=…ca.crt` | Python `ssl` module / OpenSSL — **replaces** the system store |
| `-e CURL_CA_BUNDLE=…ca.crt` | curl — **replaces** the system store |
| `-e NODE_EXTRA_CA_CERTS=…ca.crt` | Node.js — **adds** to the system store |
| `-e NODE_OPTIONS="<your value> --use-openssl-ca"` | Node.js — route through OpenSSL store (appended; your `--max-old-space-size` etc. are preserved) |
| `-e HERMES_EGRESS_PROXY=1` | Sentinel the agent can read to know it's proxy-aware |
| `-e OPENROUTER_API_KEY=<proxy-token>` | Standard provider env names receive proxy tokens so existing SDKs keep working |
| `-e HERMES_PROXY_TOKEN_<NAME>=…` | Diagnostic alias for each mapping; same value as the standard provider env var |
| `--add-host=host.docker.internal:host-gateway` | Linux-only; Docker Desktop maps it automatically |
#### Node.js asymmetric CA caveat
`REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` **replace** the system CA store inside the sandbox. `NODE_EXTRA_CA_CERTS` **adds** to it. A Node.js process inside the sandbox could in principle bypass the proxy by opening a raw `net.Socket` and starting its own TLS handshake — the system CA store would still trust real upstream certs, so the request would succeed where Python / curl would fail validation.
`NODE_OPTIONS=--use-openssl-ca` is appended to whatever you already have in `docker_env.NODE_OPTIONS`. This forces Node through the OpenSSL store that `SSL_CERT_FILE` controls, narrowing the asymmetry. It does NOT cover code that explicitly passes its own `ca` option to `tls.connect()` or `https.request()`, but it closes the easy case.
This is a known v1 limitation. Track [github.com/ironsh/iron-proxy/issues](https://github.com/ironsh/iron-proxy/issues) for an upstream resolution; in the meantime, do not run untrusted Node code that opens raw sockets in a sandbox you're depending on egress isolation for.
### docker\_env collisions
If you set proxy-controlling env vars in your `docker_env:` config block (rare but possible), Hermes refuses to start the sandbox when `enforce_on_docker: true` is set. This includes both:
- Egress-control vars: `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`
- Real provider env vars: every name in `mappings.json` (e.g. `OPENROUTER_API_KEY`, `OPENAI_API_KEY`)
Example error:
```
docker_env in config.yaml overrides egress-proxy variables
['HTTPS_PROXY', 'OPENROUTER_API_KEY']; enforce_on_docker is enabled.
Remove these keys from docker_env or disable enforce_on_docker to
opt out of egress isolation.
```
With `enforce_on_docker: false` the same situation surfaces as a warning and your `docker_env` values win — useful for migrations or testing, but you're explicitly opting OUT of the isolation guarantee.
## PID and nonce defense
The daemon's pidfile is written with `O_EXCL` + `O_NOFOLLOW` + ownership check. Concurrent `hermes egress start` calls produce one of two outcomes:
- The existing pidfile points at a live iron-proxy → second start refuses with "another start in progress" + a hint to run `hermes egress stop`
- The existing pidfile is stale (crashed daemon) → second start unlinks it and retries once
Beyond that, every `start_proxy` plants a fresh random nonce in two places:
- `HERMES_IRON_PROXY_NONCE=<nonce>` in the daemon's env
- `~/.hermes/proxy/iron-proxy.nonce` (0o600 sibling of the pidfile)
When `hermes egress stop` (or any other `_pid_alive` check) wants to confirm a PID still refers to *our* daemon — not an unrelated process that was assigned the same PID after iron-proxy crashed — it reads `/proc/<pid>/environ` and looks for the nonce. The on-disk copy is what makes this work across CLI invocations (the in-memory `_proxy_nonce` is per-process and resets on every `hermes` invocation).
If the nonce check fails, the code falls back to matching `argv[0]` basename against `iron-proxy`. `stop_proxy` additionally captures `/proc/<pid>/stat` starttime before SIGTERM and re-verifies after the 5s grace window — if starttime drifted, the PID was recycled mid-wait and SIGKILL is suppressed with a warning.
## Security model
**What this protects against:**
- Prompt-injected agent in a Docker sandbox reading `printenv` / credential files and exfiltrating real keys.
- Compromised dependency in the sandbox phoning home to an arbitrary host — default-deny allowlist blocks unknown destinations.
- Agent dialing cloud metadata endpoints (`169.254.169.254`) — iron-proxy denies these by default via `upstream_deny_cidrs`, including the IPv4-mapped-v6 form `::ffff:169.254.169.254`.
- DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time.
- Same-uid local processes reading the iron-proxy daemon's env to scrape secrets — only the env var names referenced by mappings are forwarded, not the full host env.
- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds the docker bridge gateway (Linux) or loopback (Docker Desktop), never `0.0.0.0`, so it is unreachable from the external network.
**What it does NOT protect against:**
- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise.
- **Loss of the trusted-proxy boundary itself.** The token-swap guarantee assumes the sandbox trusts the mounted CA cert (`/etc/ssl/certs/hermes-egress-ca.crt`) and that traffic actually reaches *our* iron-proxy. If the CA private key is stolen, or sandbox egress is redirected to attacker-controlled proxy infrastructure, an adversary-in-the-middle can present a valid leaf cert and the proxy tokens are no longer a meaningful boundary (cf. [MITRE ATT&CK T1588.004](https://attack.mitre.org/techniques/T1588/004/) — obtained TLS certificate material enabling AiTM). Protect the CA key (it's `0600`, host-only) and the proxy endpoint accordingly.
- Sandbox processes that bypass `HTTPS_PROXY` by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via `NODE_OPTIONS=--use-openssl-ca` (see caveat above).
- Credential files explicitly mounted into Docker (`terminal.credential_files` or skill-registered mounts). Egress protects provider env vars; it does not inspect arbitrary mounted files. Do not mount real provider credentials into an enforced egress sandbox.
- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it.
- Uncovered providers (AWS Bedrock SigV4, GCP Vertex service-account OAuth). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See [Uncovered providers](#uncovered-providers).
- iron-proxy in-memory secret zeroisation. The Go binary holds swapped-in real credentials in process memory; a core-dump or `/proc/<pid>/mem` read from a same-uid attacker would expose them. Out of scope for this layer.
## Failure modes
- **Binary not installed, `auto_install: true`** — first `hermes egress setup` or `hermes egress start` downloads it. SHA-256 verified against the upstream `checksums.txt`.
- **Binary not installed, `auto_install: false`**`start` fails with a clear message pointing to manual install.
- **`enabled: true` but proxy not running** — with `enforce_on_docker: true` (default), Docker sandbox creation refuses to start with an explanatory error. With `enforce_on_docker: false`, it falls back to direct outbound with real creds and logs a warning.
- **Port collision** — iron-proxy exits immediately; `hermes egress start` reports the last 20 log lines and fails with non-zero exit.
- **Upstream-host denied** — sandbox gets HTTP 403 from the proxy with a body explaining which host wasn't allowed. The agent sees the error and reports it.
- **Cloud metadata IP (169.254.169.254) requested** — refused by `upstream_deny_cidrs` regardless of allowlist.
- **`docker_env` collides with a proxy-controlling var (enforce on)** — sandbox creation refuses with the names of the colliding keys.
- **`docker_forward_env` tries to forward a protected provider key (enforce on)** — sandbox creation refuses; remove the key from `docker_forward_env` or opt out with `proxy.enforce_on_docker: false`.
- **`docker_extra_args` overrides proxy env/network controls (enforce on)** — sandbox creation refuses; user-supplied `-e HTTPS_PROXY=...`, `--env-file`, or `--network` args run after Hermes' generated args and can bypass egress.
- **BWS access token missing in `credential_source: bitwarden`**`hermes egress start` refuses with `--no-bitwarden` as the recovery hint.
- **iron-proxy doesn't bind within 5 seconds** — process is killed, pidfile unlinked, error names the port + tail of `iron-proxy.log`.
- **Concurrent `hermes egress start` calls** — second call refuses with "another start in progress" if the first's daemon is up; otherwise the second unlinks the stale pidfile and proceeds.
## Troubleshooting
### "Refusing to start: BWS_ACCESS_TOKEN is not set"
You enabled `credential_source: bitwarden` but the access-token env var isn't in your shell. Either:
```bash
export BWS_ACCESS_TOKEN=… # one-shot
hermes egress start
```
Or move it into `~/.hermes/.env`. Or switch back to env mode:
```bash
hermes egress setup --no-bitwarden
```
### "iron-proxy exited immediately"
Look at the last 20 lines of `~/.hermes/proxy/iron-proxy.log`. Common causes:
- Port already in use → change `proxy.tunnel_port` or kill whatever else owns 9090
- Invalid `proxy.yaml` → run `hermes egress setup` to regenerate
- CA cert / key permissions wrong → `chmod 0o600 ~/.hermes/proxy/ca.key`
### "iron-proxy did not bind \<bind-host\>:9090 within 5s"
The daemon started but never bound the listener. Usually means the binary is wedged or doing something expensive at startup. Check `~/.hermes/proxy/iron-proxy.log`. The orphan process is killed automatically and the pidfile cleaned up so you can just retry `hermes egress start`.
### Sandbox times out connecting to the proxy (Linux)
The container resolves `host.docker.internal` to the docker bridge gateway and the proxy is bound there, but a host firewall (commonly `ufw` with default-deny INPUT) drops container→host traffic on `docker0`. Verify from a container:
```bash
docker run --rm --add-host host.docker.internal:host-gateway busybox \
nc -zv -w 3 host.docker.internal 9090
```
If that times out while `hermes egress status` shows `listening`, allow the bridge subnet in your firewall, e.g. for ufw:
```bash
sudo ufw allow in on docker0 to any port 9090 proto tcp
sudo ufw allow in on docker0 to any port 9091 proto tcp
```
(9091 = the plain-HTTP forward listener on `tunnel_port + 1`.)
### Sandbox sees `HTTP 403` from the proxy
The agent inside the sandbox tried to hit a host that isn't in `proxy.extra_allowed_hosts`. The 403 body explains which host. If you want to allow it, add to your config:
```yaml
proxy:
extra_allowed_hosts:
- api.example.com
- "*.staging.example.com"
```
Then `hermes egress setup` (to regenerate `proxy.yaml`) and `hermes egress stop && hermes egress start`.
### Sandbox sees SSL verification errors
Either the CA isn't mounted in the sandbox (rare; the docker backend does this automatically when `proxy.enabled: true`), or your image's HTTP client is reading from a non-standard env var.
```bash
# Inside the sandbox:
cat /etc/ssl/certs/hermes-egress-ca.crt | head -1
# Should print: -----BEGIN CERTIFICATE-----
env | grep -E "^(REQUESTS|CURL|SSL|NODE).*CA"
# Should list all four CA-bundle env vars pointing at /etc/ssl/certs/hermes-egress-ca.crt
```
If the cert isn't there, check that `proxy.enabled: true` AND `hermes egress status` shows `Listening yes`. If the env vars are missing, the sandbox image might be running an entrypoint that strips them — check your `docker_env` config.
### Sandbox sees `HTTP 401` from upstreams
Two common causes:
1. **Token-clobber on re-setup.** You ran `hermes egress setup --rotate-tokens` (or rotated tokens some other way) and the running sandboxes still hold the old tokens. Restart the sandboxes.
2. **Bitwarden refresh failed silently.** Should not happen with the new fail-loud behavior, but if you have `proxy.allow_env_fallback: true` set, the daemon may have started with stale env values. Check the daemon's environment (`/proc/<iron-proxy-pid>/environ`) for the expected `OPENROUTER_API_KEY` etc.
### "Address in use" after the parent process died
The parent Hermes process died during `hermes egress start` (Ctrl-C during the listening probe, OOM, panic). The new fix-up logic writes the pidfile immediately after `Popen` so the orphan is recoverable:
```bash
hermes egress stop # finds the orphan via the pidfile, kills it
hermes egress start
```
If `hermes egress stop` says "iron-proxy was not running" but you can still see the daemon in `ps`, the pidfile got out of sync. Manual recovery:
```bash
pkill -TERM iron-proxy
rm -f ~/.hermes/proxy/iron-proxy.pid ~/.hermes/proxy/iron-proxy.nonce
hermes egress start
```
### Inspecting per-request behavior
On the pinned binary version (**v0.39**) both daemon-level events and per-request records land in `~/.hermes/proxy/iron-proxy.log`. The format is line-delimited JSON. Grep for a specific upstream:
```bash
grep '"upstream":"openrouter.ai"' ~/.hermes/proxy/iron-proxy.log | tail -20
```
Or watch in real-time:
```bash
tail -f ~/.hermes/proxy/iron-proxy.log | jq
```
When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-request records will move to `~/.hermes/proxy/audit.log` and `iron-proxy.log` will hold only daemon-level events. Until that bump, `audit.log` is an empty placeholder (pre-created at `0o600` so the future daemon inherits tight permissions) — wire your logrotate / monitoring tooling to `iron-proxy.log` today and plan to add `audit.log` after the version bump.
## Limitations (v1)
- Docker backend only. Modal, Daytona, and SSH wiring will follow in separate PRs.
- Providers with signature-based auth (AWS SigV4, GCP service-account OAuth) bypass the proxy entirely — see [Uncovered providers](#uncovered-providers). Header-token providers (bearer, `x-api-key`, `api-key`, `x-goog-api-key`) are all covered.
- No native Windows binary upstream. Run on Linux / macOS / WSL.
- The CA is a 10-year self-signed cert on first generation. Rotation requires `openssl genrsa ...` by hand (or wait for a follow-up that adds `hermes egress rotate-ca`).
- Re-running setup stops a running daemon after rewriting config or mappings; restart (or `hermes egress reload` for ruleset-only changes) and restart already-running sandboxes after token rotation.
- iron-proxy in-memory secret zeroisation is upstream-controlled. Same-uid attackers with `/proc/<pid>/mem` read access can read swapped-in secrets from the daemon's memory.
- iron-proxy v0.39 only supports a **single bind per daemon** (we bind the docker bridge gateway on Linux, loopback on Docker Desktop) and combines daemon + per-request records into a single log stream. When upstream adds `proxy.http_listens` (plural) and `log.audit_path`, a version bump can wire in multi-bind and the dedicated audit stream.
## See also
- Upstream project: [github.com/ironsh/iron-proxy](https://github.com/ironsh/iron-proxy)
- Upstream docs: [docs.iron.sh](https://docs.iron.sh/)
- Bitwarden integration: [`hermes secrets bitwarden`](../secrets/bitwarden)
- Hermes Docker terminal backend: [Docker](../docker)
- Developer / contributor reference: [Egress proxy internals](../../developer-guide/egress-internals)

View file

@ -39,15 +39,6 @@ const sidebars: SidebarsConfig = {
'user-guide/secrets/bitwarden',
],
},
{
type: 'category',
label: 'Egress proxy',
collapsed: true,
items: [
'user-guide/egress/index',
'user-guide/egress/iron-proxy',
],
},
'user-guide/sessions',
'user-guide/profiles',
'user-guide/profile-distributions',
@ -764,7 +755,6 @@ const sidebars: SidebarsConfig = {
'developer-guide/browser-supervisor',
'developer-guide/acp-internals',
'developer-guide/cron-internals',
'developer-guide/egress-internals',
'developer-guide/trajectory-format',
],
},