From 86fcb2fe5f7ea5f4c7ea022b8c7bb6f9d405d4c4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:49:31 -0700 Subject: [PATCH] feat(egress): first-class x-api-key providers + hot reload via management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both wired against features the iron-proxy author (@mslipper) confirmed on PR #30179 — and both verified present in the pinned v0.39.0 source. Header-auth providers (match_headers): - New _HEADER_AUTH_PROVIDERS: Anthropic native (x-api-key), Azure OpenAI (api-key on *.openai.azure.com / *.cognitiveservices / *.services.ai), Gemini (x-goog-api-key + ?key= query param via match_query). - TokenMapping grows match_headers + alias_env_names; per-provider header sets flow into the secrets rules; mappings.json roundtrips them (legacy files load with the Authorization default). - GEMINI_API_KEY / GOOGLE_API_KEY collapse into ONE mapping (two require-rules on the same host would reject each other); the sandbox gets the token under both names, and the proxy child env mirrors the alias into the canonical name when only the alias is set. - Docker backend injects alias env names alongside canonical ones. - The fail-closed tier is now empty, so fail_on_uncovered_providers and discover_blocked_providers are deleted (dead toggle otherwise); _NON_BEARER_PROVIDERS shrinks to genuinely-unswappable signature auth (AWS SigV4, GCP service-account OAuth) — warn-only, as before. Management API (hot reload): - Generated proxy.yaml enables the v0.39 management listener: loopback only at tunnel_port+2, bearer key from HERMES_IRON_PROXY_MGMT_KEY. - Key minted at setup (management.token, 0600); start_proxy injects it (v0.39 refuses to start when api_key_env is empty). - hermes egress reload -> POST /v1/reload: re-reads proxy.yaml and atomically swaps the pipeline; 422 leaves the running ruleset untouched; actionable errors for not-running / pre-management config / key mismatch. Secrets changes still require restart (daemon env is read at spawn) — the CLI says so. Validation: 218/218 unit+CLI+docker tests; 3/3 gated live E2E against the real v0.39.0 binary (Authorization swap, x-api-key swap, live reload with token rotation on the same pid). Docs updated. --- agent/proxy_sources/iron_proxy.py | 411 ++++++++++++++---- hermes_cli/config.py | 15 +- hermes_cli/main.py | 6 +- hermes_cli/proxy_cli.py | 83 ++-- tests/test_iron_proxy.py | 312 +++++++++++-- tests/test_iron_proxy_cli.py | 26 -- tests/test_iron_proxy_e2e.py | 200 +++++++++ tools/environments/docker.py | 7 +- .../docs/developer-guide/egress-internals.md | 29 +- website/docs/user-guide/egress/iron-proxy.md | 83 ++-- 10 files changed, 922 insertions(+), 250 deletions(-) diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index 6c9bc8fb96a..dae910248d5 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -105,6 +105,24 @@ _DOWNLOAD_TIMEOUT = 120 # binary is ~16MB _RUN_TIMEOUT = 30 _STARTUP_GRACE_SECONDS = 5 +# Management (operator) API. iron-proxy v0.39 ships an authenticated +# loopback HTTP endpoint (``management.listen`` + ``management.api_key_env``) +# whose ``POST /v1/reload`` re-reads proxy.yaml and atomically swaps the +# transform pipeline in-place — no restart, no dropped connections. We +# always enable it on generated configs: it binds loopback only and every +# request needs the bearer key below. ``hermes egress reload`` is the +# client. +# +# The key is minted at setup time, stored at +# ``/proxy/management.token`` (0600), and injected into the +# daemon's env under this name at start. v0.39 validates at startup that +# the named env var is non-empty when management.listen is set. +_MGMT_API_KEY_ENV = "HERMES_IRON_PROXY_MGMT_KEY" +# The management listener binds loopback at tunnel_port + 2 (tunnel_port +# is CONNECT/MITM, +1 is the plain-HTTP forward listener). +_MGMT_PORT_OFFSET = 2 +_MGMT_RELOAD_TIMEOUT = 15 + # Default listen ports. HTTPS_PROXY semantics use a single CONNECT tunnel, # so we expose only the tunnel listener for v1 — no need to put the sandbox # DNS at the iron-proxy IP. This greatly simplifies wiring. @@ -126,10 +144,7 @@ _DEFAULT_ALLOWED_HOSTS: Tuple[str, ...] = ( ) # Provider env-var name -> upstream host (or list of hosts) on which the -# Authorization Bearer token should be swapped. Only includes providers -# whose API uses a plain "Authorization: Bearer " header — providers -# with custom auth (x-api-key, query params, signatures) get added as we -# write per-provider rules. +# Authorization Bearer token should be swapped. _BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { "OPENROUTER_API_KEY": ("openrouter.ai", "*.openrouter.ai"), "OPENAI_API_KEY": ("api.openai.com",), @@ -142,58 +157,69 @@ _BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { } -# Providers whose env-var names we recognize but whose API uses a non-bearer -# auth scheme (x-api-key, AAD/OAuth, SigV4, custom signatures). When any of -# these env vars are present at proxy-start time AND -# ``proxy.fail_on_uncovered_providers`` is true (which is OFF by default), -# ``start_proxy`` refuses to start. Without this list the sandbox would -# still hold real credentials for these providers and silently bypass the -# proxy. +# Providers whose API authenticates with a NON-Authorization header. +# iron-proxy v0.39's ``secrets.replace.match_headers`` targets arbitrary +# header names (case-insensitive; confirmed by the iron-proxy author on +# PR #30179 and verified in the pinned v0.39.0 source — ``swapHeaders`` +# + ``parseHeaderMatchers``), so these are first-class swapped providers, +# not "uncovered". # -# The default is False because many of these env vars (AWS_*, -# GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_API_KEY) are present on most -# developer laptops for reasons unrelated to LLM API access — defaulting to -# refuse-start would force everyone using terraform / gcloud / aws-cli -# alongside Hermes to either unset their cloud auth or set the flag in -# config.yaml. The wizard surfaces uncovered providers at setup time and -# `hermes egress status` keeps them visible; operators who want hard -# enforcement opt in via ``proxy.fail_on_uncovered_providers: true``. +# ``aliases`` are interchangeable env-var names for the SAME upstream +# credential (Hermes' auth.py keys Google on both GEMINI_API_KEY and +# GOOGLE_API_KEY). Aliased names MUST collapse into a single mapping: +# every rule carries ``require: true``, and two require-rules on the same +# host reject each other's requests (each rule whose own token isn't +# present returns ActionReject). The sandbox receives the minted token +# under the canonical name AND every alias so SDKs reading either work. +_HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = { + # Anthropic native: x-api-key. Authorization is also matched so an + # SDK sending the token as a Bearer (OAuth-style) still swaps. + "ANTHROPIC_API_KEY": { + "hosts": ("api.anthropic.com",), + "match_headers": ("x-api-key", "Authorization"), + "aliases": (), + }, + # Azure OpenAI: api-key header (AAD bearer flows use Authorization). + "AZURE_OPENAI_API_KEY": { + "hosts": ( + "*.openai.azure.com", + "*.cognitiveservices.azure.com", + "*.services.ai.azure.com", + ), + "match_headers": ("api-key", "Authorization"), + "aliases": (), + }, + # Google AI Studio (Gemini): x-goog-api-key header; the SDKs that pass + # ``?key=`` as a query param are covered by match_query, which + # scans every query parameter for the token value. + "GEMINI_API_KEY": { + "hosts": ("generativelanguage.googleapis.com",), + "match_headers": ("x-goog-api-key",), + "aliases": ("GOOGLE_API_KEY",), + }, +} + + +# Providers whose env-var names we recognize but whose auth genuinely cannot +# be swapped by a static header/query replacement (SigV4 request signing, +# OAuth tokens minted by an SDK from a service-account file). Presence is +# surfaced as a warning at setup/status time — these are generic cloud creds +# that are usually present for unrelated tooling (terraform, gcloud, aws-cli), +# so they never block the proxy from starting. # -# Bare strings here are env-var names; the proxy doesn't try to wire them up, -# only flags their presence so the operator knows isolation is incomplete. +# NOTE: this list used to include Anthropic / Azure OpenAI / Gemini, with an +# LLM-specific fail-closed tier (``proxy.fail_on_uncovered_providers``). +# Those providers moved to ``_HEADER_AUTH_PROVIDERS`` once we wired +# ``match_headers`` (upstream confirmed support on the pinned v0.39.0), which +# emptied the fail-closed tier — the flag and its refuse-start path were +# deleted rather than kept as a dead toggle. _NON_BEARER_PROVIDERS: Tuple[str, ...] = ( - # Anthropic native uses x-api-key, not Authorization: Bearer. - "ANTHROPIC_API_KEY", - # Azure OpenAI: api-key header + optional AAD bearer. - "AZURE_OPENAI_API_KEY", # AWS Bedrock / SageMaker: SigV4-signed requests. "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", - # GCP Vertex AI: OAuth bearer from gcloud SDK, not a static env key. + # GCP Vertex AI: OAuth bearer minted by the SDK from a service-account + # file, not a static env key. "GOOGLE_APPLICATION_CREDENTIALS", - # Google AI Studio (Gemini): x-goog-api-key OR query param. - "GEMINI_API_KEY", - "GOOGLE_API_KEY", -) - - -# Tier of `_NON_BEARER_PROVIDERS` that's LLM-specific enough that any -# accidental sandbox bypass is a real isolation failure. When -# ``fail_on_uncovered_providers`` is true, only env vars in this tier -# cause refuse-start; the rest are warn-only via `_NON_BEARER_PROVIDERS`. -# Splitting this avoids tripping every operator with `AWS_PROFILE` set -# for unrelated cloud work. -_LLM_SPECIFIC_NON_BEARER_PROVIDERS: Tuple[str, ...] = ( - "ANTHROPIC_API_KEY", - "AZURE_OPENAI_API_KEY", - "GEMINI_API_KEY", - # GOOGLE_API_KEY is an interchangeable alias for GEMINI_API_KEY in - # Hermes (auth.py keys Google on both; the native Gemini adapter - # accepts either) and authenticates the same generativelanguage - # LLM endpoint. It belongs in the fail-closed tier too — otherwise - # an operator with only GOOGLE_API_KEY set who enables - # fail_on_uncovered_providers gets a false sense of coverage. - "GOOGLE_API_KEY", ) @@ -306,11 +332,23 @@ class TokenMapping: When Bitwarden is configured as the credential source for the proxy, iron-proxy's *own* environment is populated from bws on startup — the sandbox still sees only ``proxy_token``. + + ``match_headers`` names the request headers iron-proxy scans for the + proxy token (default: ``Authorization`` for bearer providers; e.g. + ``("x-api-key", "Authorization")`` for Anthropic native). + + ``alias_env_names`` are additional env-var names the SANDBOX receives + the same proxy token under (e.g. ``GOOGLE_API_KEY`` for + ``GEMINI_API_KEY``). They do not appear in the iron-proxy config — + only one secrets rule is emitted per mapping, keyed on + ``real_env_name``. """ proxy_token: str real_env_name: str upstream_hosts: Tuple[str, ...] + match_headers: Tuple[str, ...] = ("Authorization",) + alias_env_names: Tuple[str, ...] = () # --------------------------------------------------------------------------- @@ -800,6 +838,160 @@ def mint_proxy_token(prefix: str = "hermes-proxy") -> str: return f"{prefix}-{hashlib.sha256(os.urandom(32)).hexdigest()[:32]}" +def _management_token_path() -> Path: + return _proxy_state_dir() / "management.token" + + +def ensure_management_token(*, force: bool = False) -> str: + """Return the management-API bearer key, minting it on first call. + + Stored at ``/proxy/management.token`` with 0600 perms. + The daemon receives it via the ``HERMES_IRON_PROXY_MGMT_KEY`` env var + (named in the generated config's ``management.api_key_env``); + ``hermes egress reload`` reads the same file to authenticate. + """ + + p = _management_token_path() + if not force and p.exists(): + try: + existing = p.read_text(encoding="utf-8").strip() + if existing: + return existing + except OSError: + pass + token = mint_proxy_token(prefix="hermes-mgmt") + fd = os.open( + str(p), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + try: + os.fchmod(fd, 0o600) + except (OSError, AttributeError): + pass + try: + os.write(fd, token.encode("utf-8")) + finally: + os.close(fd) + return token + + +def _read_management_token() -> Optional[str]: + p = _proxy_state_dir_ro() / "management.token" + try: + token = p.read_text(encoding="utf-8").strip() + except OSError: + return None + return token or None + + +def _read_management_listen_from_config( + config_path: Optional[Path] = None, +) -> Optional[Tuple[str, int]]: + """Return ``(host, port)`` of the management listener, if configured.""" + + cfg = config_path or (_proxy_state_dir_ro() / "proxy.yaml") + if not cfg.exists(): + return None + try: + import yaml + except ImportError: + return None + try: + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return None + listen = ((data or {}).get("management") or {}).get("listen") or "" + if not isinstance(listen, str) or ":" not in listen: + return None + host, _, port_s = listen.rpartition(":") + try: + port = int(port_s) + except ValueError: + return None + return (host or "127.0.0.1", port) + + +def reload_proxy() -> bool: + """Hot-reload the running daemon's ruleset via the management API. + + POSTs to ``/v1/reload`` on the loopback management listener; the daemon + re-reads proxy.yaml and atomically swaps the transform pipeline — + validation failures leave the running config untouched (HTTP 422). + + Returns True on a successful reload. Raises ``RuntimeError`` with an + actionable message when the daemon isn't running, the config predates + management-API support (no ``management`` block → restart required), + or the reload is rejected. + """ + + pid = _read_pid() + if not pid or not _pid_alive(pid): + raise RuntimeError( + "iron-proxy is not running — nothing to reload. " + "Run `hermes egress start`." + ) + mgmt = _read_management_listen_from_config() + if mgmt is None: + raise RuntimeError( + "The generated proxy.yaml has no management listener (written " + "before reload support). Re-run `hermes egress setup` and use " + "`hermes egress restart` this one time." + ) + token = _read_management_token() + if not token: + raise RuntimeError( + "management.token is missing — re-run `hermes egress setup`, " + "then `hermes egress restart`." + ) + + import urllib.error + import urllib.request + + host, port = mgmt + req = urllib.request.Request( + f"http://{host}:{port}/v1/reload", + method="POST", + headers={"Authorization": f"Bearer {token}"}, + data=b"", + ) + try: + with urllib.request.urlopen(req, timeout=_MGMT_RELOAD_TIMEOUT) as resp: + if resp.status == 200: + return True + raise RuntimeError( + f"management API returned unexpected status {resp.status}" + ) + except urllib.error.HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8", errors="replace")[:500] + except OSError: + pass + if exc.code == 422: + raise RuntimeError( + f"iron-proxy rejected the new config (validation failed; " + f"the running ruleset is unchanged): {body}" + ) from exc + if exc.code == 401: + raise RuntimeError( + "management API rejected our key (401). The running " + "daemon was started with a different management.token — " + "run `hermes egress restart`." + ) from exc + raise RuntimeError( + f"management reload failed (HTTP {exc.code}): {body}" + ) from exc + except (urllib.error.URLError, OSError) as exc: + # A daemon started from a pre-management config is alive but has + # no listener on the management port. + raise RuntimeError( + f"could not reach the management API at {host}:{port} ({exc}). " + "If the daemon was started before reload support, run " + "`hermes egress restart` once." + ) from exc + + def _default_http_listen(tunnel_port: int) -> List[str]: """Build the single host:port bind the proxy should listen on. @@ -962,13 +1154,22 @@ def build_proxy_config( secrets_rules = [] for m in mappings: + match_headers = list(m.match_headers or ("Authorization",)) secrets_rules.append({ "source": {"type": "env", "var": m.real_env_name}, "replace": { "proxy_value": m.proxy_token, - "match_headers": ["Authorization"], - # The token is also accepted as a bearer query param in case - # the sandbox passes it that way. Body matching is off — we + # Per-provider header set: bearer providers match only + # Authorization; header-auth providers (Anthropic native + # x-api-key, Azure api-key, Gemini x-goog-api-key) match + # their native header (+ Authorization where the provider + # also accepts bearer flows). v0.39 matches header names + # case-insensitively — see parseHeaderMatchers upstream. + "match_headers": match_headers, + # The token is also accepted as a query param — v0.39 scans + # every query parameter for the token value, which covers + # SDKs that pass ``?key=`` (Gemini) as well as + # bearer-in-query styles. Body matching is off — we # don't want body inspection forced for every request. "match_query": True, "match_body": False, @@ -1085,6 +1286,18 @@ def build_proxy_config( "metrics": { "listen": "127.0.0.1:0", }, + # Operator-facing management API — loopback only, bearer-key + # authenticated (key read from the env var named below; injected + # by ``start_proxy`` from ``management.token``). ``POST /v1/reload`` + # re-reads THIS config file and atomically swaps the transform + # pipeline — `hermes egress reload` applies allowlist/token/mapping + # changes without a restart. Loopback deliberately: sandboxes must + # never reach the management surface, so it does NOT bind the + # docker bridge like the traffic listeners do. + "management": { + "listen": f"127.0.0.1:{tunnel_port + _MGMT_PORT_OFFSET}", + "api_key_env": _MGMT_API_KEY_ENV, + }, "tls": { "ca_cert": str(ca_cert), "ca_key": str(ca_key), @@ -1189,6 +1402,8 @@ def write_mappings(mappings: List[TokenMapping]) -> Path: "proxy_token": m.proxy_token, "env_name": m.real_env_name, "upstream_hosts": list(m.upstream_hosts), + "match_headers": list(m.match_headers), + "alias_env_names": list(m.alias_env_names), } for m in mappings ], @@ -1223,6 +1438,11 @@ def load_mappings() -> List[TokenMapping]: proxy_token=item["proxy_token"], real_env_name=item["env_name"], upstream_hosts=tuple(item.get("upstream_hosts") or ()), + # Pre-header-auth mappings.json files (written before the + # match_headers/alias fields existed) load with the bearer + # defaults — identical to their behavior at write time. + match_headers=tuple(item.get("match_headers") or ("Authorization",)), + alias_env_names=tuple(item.get("alias_env_names") or ()), )) except (KeyError, TypeError): continue @@ -1255,6 +1475,24 @@ def discover_provider_mappings( real_env_name=env_name, upstream_hosts=hosts, )) + for env_name, spec in _HEADER_AUTH_PROVIDERS.items(): + aliases = tuple(spec.get("aliases") or ()) + # A mapping is minted when the canonical name OR any alias is + # available. Aliases collapse into ONE mapping (single secrets + # rule) because two require-rules on the same host would reject + # each other's requests. The canonical env name is what + # iron-proxy reads — when only the alias is set in the host env, + # the subprocess-env builder mirrors it (see + # ``_build_proxy_subprocess_env``). + if env_name not in names and not any(a in names for a in aliases): + continue + mappings.append(TokenMapping( + proxy_token=mint_proxy_token(prefix=env_name.lower().replace("_api_key", "")), + real_env_name=env_name, + upstream_hosts=tuple(spec["hosts"]), + match_headers=tuple(spec["match_headers"]), + alias_env_names=aliases, + )) return mappings @@ -1264,15 +1502,14 @@ def discover_uncovered_providers( ) -> List[str]: """Return env-var names for providers we recognize but can't proxy. - Anthropic native (x-api-key), AWS Bedrock (SigV4), Azure OpenAI - (api-key), etc. When any of these are configured, the sandbox is - holding real credentials that the proxy can't strip — the isolation - guarantee is incomplete for those providers. + AWS Bedrock (SigV4) and GCP Vertex (SDK-minted OAuth) can't be swapped + by a static header replacement. When any of these are configured, the + sandbox is holding real credentials that the proxy can't strip — the + isolation guarantee is incomplete for those providers. - The wizard uses this to print a warning at setup time; ``start_proxy`` - can be configured to refuse to start when ``fail_on_uncovered_providers`` - is true (see :func:`discover_blocked_providers` for the strict tier - that actually blocks). + The wizard and ``hermes egress status`` use this to print a warning. + (Anthropic / Azure OpenAI / Gemini used to be here; they're now + first-class swapped providers via ``_HEADER_AUTH_PROVIDERS``.) """ if available_env_names is not None: @@ -1283,26 +1520,6 @@ def discover_uncovered_providers( return [n for n in _NON_BEARER_PROVIDERS if n in names] -def discover_blocked_providers( - *, - available_env_names: Optional[List[str]] = None, -) -> List[str]: - """Return env-var names for non-bearer providers that BLOCK start. - - Subset of :func:`discover_uncovered_providers` that's LLM-specific - enough to refuse-start when ``proxy.fail_on_uncovered_providers`` is - true. Excludes generic cloud creds (AWS_*, GCP application-default) - that are usually present for unrelated tooling. - """ - - if available_env_names is not None: - names = set(available_env_names) - else: - names = {k for k, v in os.environ.items() if v} - - return [n for n in _LLM_SPECIFIC_NON_BEARER_PROVIDERS if n in names] - - def merge_mappings( *, existing: List[TokenMapping], @@ -1330,12 +1547,15 @@ def merge_mappings( for d in discovered: prior = by_name.get(d.real_env_name) if prior is not None and not rotate: - # Preserve the token, refresh the host list in case we added - # new upstreams since last setup. + # Preserve the token; refresh hosts/headers/aliases in case + # the provider spec changed since last setup (new upstreams, + # a provider moving from uncovered to header-auth, etc). out.append(TokenMapping( proxy_token=prior.proxy_token, real_env_name=prior.real_env_name, upstream_hosts=d.upstream_hosts, + match_headers=d.match_headers, + alias_env_names=d.alias_env_names, )) else: out.append(d) @@ -1588,6 +1808,13 @@ def start_proxy( bitwarden_config=bitwarden_config, ) + # If the generated config enables the management API, the daemon + # validates at startup that the api_key_env is non-empty. Inject the + # persisted key (minting it if this is a config written by a newer + # setup but the token file was removed). + if _read_management_listen_from_config(cfg) is not None: + env[_MGMT_API_KEY_ENV] = ensure_management_token() + # Plant a per-start nonce in the child env so ``_pid_alive`` can # confirm a candidate PID still refers to *our* binary across PID # recycling. Module-global is fine — only one managed proxy per @@ -1903,11 +2130,24 @@ def _build_proxy_subprocess_env( # The proxy reads the real upstream secrets from its OWN env, indexed # by ``m.real_env_name`` in the YAML config's ``secrets.source.var`` - # field. Forward those — but only those. - needed = {m.real_env_name for m in load_mappings()} + # field. Forward those — but only those. For alias providers + # (GEMINI_API_KEY / GOOGLE_API_KEY), the rule is keyed on the canonical + # name; when only the alias is set in the host env, mirror its value + # into the canonical name so the swap still has a real secret. + alias_sources: Dict[str, Tuple[str, ...]] = {} + needed = set() + for m in load_mappings(): + needed.add(m.real_env_name) + if m.alias_env_names: + alias_sources[m.real_env_name] = tuple(m.alias_env_names) for name in needed: if name in parent: env[name] = parent[name] + else: + for alias in alias_sources.get(name, ()): + if parent.get(alias): + env[name] = parent[alias] + break # Optional Bitwarden refresh path. Pulled lazily so the proxy module # doesn't hard-depend on the bitwarden module being importable in @@ -2235,10 +2475,10 @@ __all__ = [ "TokenMapping", "build_proxy_config", "discover_provider_mappings", - "discover_blocked_providers", "discover_uncovered_providers", "ensure_audit_log", "ensure_ca_cert", + "ensure_management_token", "find_iron_proxy", "get_status", "install_iron_proxy", @@ -2246,6 +2486,7 @@ __all__ = [ "load_mappings", "merge_mappings", "mint_proxy_token", + "reload_proxy", "start_proxy", "stop_proxy", "write_mappings", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5b4914ce374..9198e101b2f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3006,15 +3006,12 @@ DEFAULT_CONFIG = { # 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, - # When true, `hermes egress start` refuses to start if any provider - # env var is set that the proxy cannot strip (Anthropic native - # `x-api-key`, Azure OpenAI api-key, Gemini x-goog-api-key). - # These LLM-specific credentials would otherwise leak into the - # sandbox bypassing the proxy. Generic cloud creds (AWS_*, - # GOOGLE_APPLICATION_CREDENTIALS) are warned about but never - # block. Defaults to false because false positives (operator has - # the env set but doesn't actually use that provider) are common. - "fail_on_uncovered_providers": False, + # 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 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5514535f14a..a52528fcaa1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13317,9 +13317,9 @@ def main(): # 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 because of fail_on_uncovered_ - # providers) actually exit non-zero. Handlers that return None - # are treated as success (exit 0). + # ``hermes egress start`` refusing when credential_source=bitwarden + # is misconfigured) actually exit non-zero. Handlers that return + # None are treated as success (exit 0). if hasattr(args, "func"): rc = args.func(args) if isinstance(rc, int) and rc != 0: diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py index dd23633a7f7..2b90ec90a6c 100644 --- a/hermes_cli/proxy_cli.py +++ b/hermes_cli/proxy_cli.py @@ -108,6 +108,13 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: ) 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", @@ -323,7 +330,7 @@ def cmd_setup(args: argparse.Namespace) -> int: return 1 # Warn the operator about providers we recognize but can't proxy - # (Anthropic native, AWS Bedrock, Azure OpenAI, etc). These still + # (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, @@ -337,9 +344,10 @@ def cmd_setup(args: argparse.Namespace) -> int: for name in uncovered: console.print(f" - {name}") console.print( - " [dim]These providers use non-bearer auth (x-api-key, " - "SigV4, etc.) and will hold real credentials inside the " - "sandbox. Egress isolation is INCOMPLETE for these.[/dim]" + " [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") @@ -412,6 +420,12 @@ def cmd_setup(args: argparse.Namespace) -> int: ) 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: @@ -449,7 +463,6 @@ def cmd_setup(args: argparse.Namespace) -> int: ) else: proxy_cfg["credential_source"] = "env" - proxy_cfg.setdefault("fail_on_uncovered_providers", False) save_config(cfg) live_status = ip.get_status() @@ -522,6 +535,8 @@ def cmd_setup(args: argparse.Namespace) -> int: 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]" @@ -588,29 +603,10 @@ def cmd_start(args: argparse.Namespace) -> int: proxy_cfg.get("allow_env_fallback", False) ) - # fail_on_uncovered_providers: when true, refuse to start if any - # LLM-specific non-bearer providers (Anthropic native, Azure OpenAI, - # Gemini) have env vars set in the host process — those would - # otherwise leak real credentials into the sandbox while bypassing - # the proxy. Only the strict LLM-specific subset blocks; generic - # cloud creds (AWS_*, GOOGLE_APPLICATION_CREDENTIALS) still surface - # as warnings via `discover_uncovered_providers` but don't block, to - # avoid tripping every operator with terraform / gcloud set up. - if bool(proxy_cfg.get("fail_on_uncovered_providers", False)): - blocked = ip.discover_blocked_providers() - if blocked: - console.print( - "[red]✗ Refusing to start: provider env vars present " - "that bypass the proxy:[/red]" - ) - for name in blocked: - console.print(f" - {name}") - console.print( - " Set `proxy.fail_on_uncovered_providers: false` in " - "config.yaml to start anyway (sandbox will hold real " - "credentials for those providers)." - ) - return 1 + # 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 — @@ -683,7 +679,7 @@ def cmd_restart(args: argparse.Namespace) -> int: 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 - and fail-on-uncovered-provider guards run exactly as they do for ``start``. + guards run exactly as they do for ``start``. """ console = Console() was_running = ip.stop_proxy() @@ -692,6 +688,35 @@ def cmd_restart(args: argparse.Namespace) -> int: 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() diff --git a/tests/test_iron_proxy.py b/tests/test_iron_proxy.py index 71f2b51d960..73b798e0fac 100644 --- a/tests/test_iron_proxy.py +++ b/tests/test_iron_proxy.py @@ -471,16 +471,16 @@ def test_merge_mappings_rotate_mints_fresh_tokens(): # --------------------------------------------------------------------------- -# Uncovered provider detection (regression: non-bearer providers bypass) +# Uncovered provider detection (regression: signature-auth providers bypass) # --------------------------------------------------------------------------- -def test_uncovered_providers_detects_anthropic_aws(hermes_home, monkeypatch): - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") +def test_uncovered_providers_detects_aws_gcp_appdefault(hermes_home, monkeypatch): monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json") uncovered = ip.discover_uncovered_providers() - assert "ANTHROPIC_API_KEY" in uncovered assert "AWS_ACCESS_KEY_ID" in uncovered + assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered def test_uncovered_providers_explicit_names_empty(): @@ -496,6 +496,22 @@ def test_uncovered_providers_skips_bearer_providers(hermes_home, monkeypatch): assert "OPENROUTER_API_KEY" not in uncovered +def test_uncovered_providers_skips_header_auth_providers(hermes_home, monkeypatch): + """Anthropic / Azure / Gemini moved from 'uncovered' to first-class + header-auth swapped providers — they must no longer be reported as + uncovered.""" + + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "az-test") + monkeypatch.setenv("GEMINI_API_KEY", "g-test") + monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias") + uncovered = ip.discover_uncovered_providers() + assert "ANTHROPIC_API_KEY" not in uncovered + assert "AZURE_OPENAI_API_KEY" not in uncovered + assert "GEMINI_API_KEY" not in uncovered + assert "GOOGLE_API_KEY" not in uncovered + + # --------------------------------------------------------------------------- # Binary discovery + lazy install # --------------------------------------------------------------------------- @@ -1357,40 +1373,276 @@ def test_default_deny_includes_ipv4_mapped_v6(tmp_path): # --------------------------------------------------------------------------- -# v3: split LLM-specific blocked tier (P1 #3 + P2 non_bearer tiers) +# Header-auth providers (x-api-key family) — match_headers + aliases # --------------------------------------------------------------------------- -def test_blocked_providers_subset_of_uncovered(hermes_home, monkeypatch): - """The strict tier that BLOCKS start must be a subset of the - uncovered-but-warn tier; the wizard surfaces ALL uncovered but only - blocks the LLM-specific ones.""" +def test_header_auth_providers_discovered(hermes_home, monkeypatch): + """Anthropic / Azure / Gemini mint mappings with their native auth + headers instead of landing in the uncovered list.""" + + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "az-test") + monkeypatch.setenv("GEMINI_API_KEY", "g-test") + + ms = {m.real_env_name: m for m in ip.discover_provider_mappings()} + assert "ANTHROPIC_API_KEY" in ms + assert "AZURE_OPENAI_API_KEY" in ms + assert "GEMINI_API_KEY" in ms + + anth = ms["ANTHROPIC_API_KEY"] + assert "x-api-key" in anth.match_headers + assert "api.anthropic.com" in anth.upstream_hosts + + azure = ms["AZURE_OPENAI_API_KEY"] + assert "api-key" in azure.match_headers + + gem = ms["GEMINI_API_KEY"] + assert "x-goog-api-key" in gem.match_headers + assert "GOOGLE_API_KEY" in gem.alias_env_names + + +def test_gemini_alias_collapses_to_single_mapping(hermes_home, monkeypatch): + """GEMINI_API_KEY + GOOGLE_API_KEY are the same upstream credential — + two require-rules on the same host would reject each other's requests, + so exactly ONE mapping must be minted regardless of which names are + set.""" - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant") - monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA-test") - monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json") monkeypatch.setenv("GEMINI_API_KEY", "g-test") monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias") + ms = [m for m in ip.discover_provider_mappings() + if "generativelanguage" in " ".join(m.upstream_hosts)] + assert len(ms) == 1 + assert ms[0].real_env_name == "GEMINI_API_KEY" - uncovered = set(ip.discover_uncovered_providers()) - blocked = set(ip.discover_blocked_providers()) - # Strict subset: every blocked is also uncovered, but the reverse - # doesn't hold. - assert blocked.issubset(uncovered) - # AWS / GCP appdefault present but NOT blocked (those are present on - # most dev laptops for unrelated cloud tooling). - assert "AWS_ACCESS_KEY_ID" in uncovered - assert "AWS_ACCESS_KEY_ID" not in blocked - assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered - assert "GOOGLE_APPLICATION_CREDENTIALS" not in blocked - # LLM-specific providers ARE blocked. - assert "ANTHROPIC_API_KEY" in blocked - assert "GEMINI_API_KEY" in blocked - # GOOGLE_API_KEY is an alias for GEMINI_API_KEY (same generativelanguage - # LLM endpoint) — it must be in the fail-closed tier, not warn-only, - # or fail_on_uncovered_providers gives false coverage. - assert "GOOGLE_API_KEY" in blocked +def test_gemini_alias_only_still_mints_mapping(hermes_home, monkeypatch): + """An operator with ONLY GOOGLE_API_KEY set still gets Gemini coverage + (mapping keyed on the canonical name).""" + + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias") + ms = {m.real_env_name: m for m in ip.discover_provider_mappings()} + assert "GEMINI_API_KEY" in ms + assert "GOOGLE_API_KEY" in ms["GEMINI_API_KEY"].alias_env_names + + +def test_build_proxy_config_emits_per_provider_match_headers(tmp_path): + """Header-auth mappings carry their native headers into the secrets + rule; bearer mappings keep the Authorization default.""" + + bearer = _sample_mapping() + anth = ip.TokenMapping( + proxy_token=ip.mint_proxy_token("anthropic"), + real_env_name="ANTHROPIC_API_KEY", + upstream_hosts=("api.anthropic.com",), + match_headers=("x-api-key", "Authorization"), + ) + cfg = ip.build_proxy_config( + mappings=[bearer, anth], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + ) + rules = {r["source"]["var"]: r for r in cfg["transforms"][1]["config"]["secrets"]} + assert rules["OPENROUTER_API_KEY"]["replace"]["match_headers"] == ["Authorization"] + assert rules["ANTHROPIC_API_KEY"]["replace"]["match_headers"] == [ + "x-api-key", "Authorization", + ] + # Fail-closed still applies to header-auth providers. + assert rules["ANTHROPIC_API_KEY"]["replace"]["require"] is True + # Header-auth hosts land on the allowlist too. + assert "api.anthropic.com" in cfg["transforms"][0]["config"]["domains"] + + +def test_mappings_roundtrip_preserves_headers_and_aliases(hermes_home): + m = ip.TokenMapping( + proxy_token=ip.mint_proxy_token("gemini"), + real_env_name="GEMINI_API_KEY", + upstream_hosts=("generativelanguage.googleapis.com",), + match_headers=("x-goog-api-key",), + alias_env_names=("GOOGLE_API_KEY",), + ) + ip.write_mappings([m]) + loaded = ip.load_mappings() + assert loaded[0].match_headers == ("x-goog-api-key",) + assert loaded[0].alias_env_names == ("GOOGLE_API_KEY",) + + +def test_load_mappings_legacy_entries_default_to_bearer(hermes_home): + """mappings.json written before the match_headers/alias fields must + load with the Authorization default (same behavior as at write time).""" + + import json as _json + state = ip._proxy_state_dir() + (state / "mappings.json").write_text(_json.dumps({ + "version": 1, + "tokens": [{ + "proxy_token": "openrouter-legacy-token", + "env_name": "OPENROUTER_API_KEY", + "upstream_hosts": ["openrouter.ai"], + }], + }), encoding="utf-8") + loaded = ip.load_mappings() + assert loaded[0].match_headers == ("Authorization",) + assert loaded[0].alias_env_names == () + + +def test_subprocess_env_mirrors_alias_into_canonical(hermes_home, monkeypatch): + """When only the alias (GOOGLE_API_KEY) is exported, the proxy child + env must still carry the canonical name the secrets rule reads.""" + + m = ip.TokenMapping( + proxy_token=ip.mint_proxy_token("gemini"), + real_env_name="GEMINI_API_KEY", + upstream_hosts=("generativelanguage.googleapis.com",), + match_headers=("x-goog-api-key",), + alias_env_names=("GOOGLE_API_KEY",), + ) + ip.write_mappings([m]) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-real-secret") + env = ip._build_proxy_subprocess_env() + assert env.get("GEMINI_API_KEY") == "g-real-secret" + + +def test_subprocess_env_canonical_wins_over_alias(hermes_home, monkeypatch): + m = ip.TokenMapping( + proxy_token=ip.mint_proxy_token("gemini"), + real_env_name="GEMINI_API_KEY", + upstream_hosts=("generativelanguage.googleapis.com",), + match_headers=("x-goog-api-key",), + alias_env_names=("GOOGLE_API_KEY",), + ) + ip.write_mappings([m]) + monkeypatch.setenv("GEMINI_API_KEY", "g-canonical") + monkeypatch.setenv("GOOGLE_API_KEY", "g-alias") + env = ip._build_proxy_subprocess_env() + assert env.get("GEMINI_API_KEY") == "g-canonical" + + +# --------------------------------------------------------------------------- +# Management API (hot reload) +# --------------------------------------------------------------------------- + + +def test_build_proxy_config_enables_management_listener(tmp_path): + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + tunnel_port=9090, + ) + mgmt = cfg["management"] + # Loopback only — sandboxes must never reach the management surface. + assert mgmt["listen"].startswith("127.0.0.1:") + assert mgmt["listen"].endswith(":9092") # tunnel_port + 2 + assert mgmt["api_key_env"] == ip._MGMT_API_KEY_ENV + + +def test_ensure_management_token_persists_and_is_stable(hermes_home): + t1 = ip.ensure_management_token() + t2 = ip.ensure_management_token() + assert t1 == t2 + assert t1.startswith("hermes-mgmt-") + p = ip._proxy_state_dir() / "management.token" + assert p.exists() + assert (p.stat().st_mode & 0o777) == 0o600 + + +def test_ensure_management_token_force_rotates(hermes_home): + t1 = ip.ensure_management_token() + t2 = ip.ensure_management_token(force=True) + assert t1 != t2 + + +def test_reload_proxy_refuses_when_not_running(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "_read_pid", lambda: None) + with pytest.raises(RuntimeError, match="not running"): + ip.reload_proxy() + + +def test_reload_proxy_refuses_on_pre_management_config(hermes_home, monkeypatch): + """A config written before management support has no listener — the + error must tell the operator to re-setup + restart once.""" + + monkeypatch.setattr(ip, "_read_pid", lambda: 4242) + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + # No proxy.yaml at all -> _read_management_listen_from_config is None. + with pytest.raises(RuntimeError, match="restart"): + ip.reload_proxy() + + +def test_reload_proxy_posts_bearer_to_management_endpoint(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "_read_pid", lambda: 4242) + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr( + ip, "_read_management_listen_from_config", + lambda config_path=None: ("127.0.0.1", 9092), + ) + ip.ensure_management_token() + + captured = {} + + class _FakeResp: + status = 200 + def __enter__(self): + return self + def __exit__(self, *a): + return False + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["method"] = req.get_method() + captured["auth"] = req.get_header("Authorization") + return _FakeResp() + + import urllib.request + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + assert ip.reload_proxy() is True + assert captured["url"] == "http://127.0.0.1:9092/v1/reload" + assert captured["method"] == "POST" + token = (ip._proxy_state_dir() / "management.token").read_text().strip() + assert captured["auth"] == f"Bearer {token}" + + +def test_start_proxy_injects_management_key_env(hermes_home, monkeypatch): + """When the generated config has a management listener, start_proxy + must inject the bearer key env var — v0.39 refuses to start when + api_key_env is empty.""" + + cfg_path = ip._proxy_state_dir() / "proxy.yaml" + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=hermes_home / "ca.crt", + ca_key=hermes_home / "ca.key", + http_listen=["127.0.0.1:9090"], + ) + ip.write_proxy_config(cfg) + (hermes_home / "bin").mkdir(parents=True, exist_ok=True) + fake_bin = hermes_home / "bin" / "iron-proxy" + fake_bin.write_text("#!/bin/sh\nsleep 60\n") + fake_bin.chmod(0o755) + + captured_env = {} + + class _FakeProc: + pid = 99999 + def poll(self): + return None + + def fake_popen(cmd, **kw): + captured_env.update(kw.get("env") or {}) + return _FakeProc() + + monkeypatch.setattr(ip.subprocess, "Popen", fake_popen) + monkeypatch.setattr(ip, "_write_pidfile_safely", lambda pf, pid: None) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus(pid=99999, listening=True)) + + ip.start_proxy(binary=fake_bin, config_path=cfg_path, install_if_missing=False) + assert captured_env.get(ip._MGMT_API_KEY_ENV) + assert captured_env[ip._MGMT_API_KEY_ENV].startswith("hermes-mgmt-") # --------------------------------------------------------------------------- diff --git a/tests/test_iron_proxy_cli.py b/tests/test_iron_proxy_cli.py index fed235d2472..f3a6c9ba8cc 100644 --- a/tests/test_iron_proxy_cli.py +++ b/tests/test_iron_proxy_cli.py @@ -198,7 +198,6 @@ def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, b # --------------------------------------------------------------------------- -# cmd_start — fail_on_uncovered_providers + Bitwarden rotation wire-up # --------------------------------------------------------------------------- @@ -212,21 +211,6 @@ def test_cmd_start_refuses_when_proxy_disabled(hermes_home, monkeypatch): assert rc == 1 -def test_cmd_start_refuses_on_uncovered_provider_when_strict(hermes_home, monkeypatch): - """fail_on_uncovered_providers=true + ANTHROPIC_API_KEY in env = - refuse to start (real credential would otherwise leak into sandbox).""" - - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["fail_on_uncovered_providers"] = True - save_config(cfg) - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") - - 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 @@ -244,7 +228,6 @@ def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch): monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) rc = proxy_cli.cmd_start(_args()) assert rc == 0 @@ -262,7 +245,6 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa cfg = load_config() cfg.setdefault("proxy", {})["enabled"] = True cfg["proxy"]["credential_source"] = "bitwarden" - cfg["proxy"]["fail_on_uncovered_providers"] = False cfg.setdefault("secrets", {})["bitwarden"] = { "enabled": True, "project_id": "test-proj-id", @@ -284,7 +266,6 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa return s monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) rc = proxy_cli.cmd_start(_args()) assert rc == 0 @@ -301,7 +282,6 @@ def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch cfg = load_config() cfg.setdefault("proxy", {})["enabled"] = True cfg["proxy"]["credential_source"] = "bitwarden" - cfg["proxy"]["fail_on_uncovered_providers"] = False cfg.setdefault("secrets", {})["bitwarden"] = { "enabled": True, "project_id": "test-proj-id", @@ -315,7 +295,6 @@ def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch 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: []) - monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) rc = proxy_cli.cmd_start(_args()) assert rc == 1 @@ -328,7 +307,6 @@ def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env cfg = load_config() cfg.setdefault("proxy", {})["enabled"] = True cfg["proxy"]["credential_source"] = "env" - cfg["proxy"]["fail_on_uncovered_providers"] = False save_config(cfg) captured: dict = {} @@ -553,7 +531,6 @@ def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeyp cfg = load_config() cfg.setdefault("proxy", {})["enabled"] = True cfg["proxy"]["credential_source"] = "bitwarden" - cfg["proxy"]["fail_on_uncovered_providers"] = False cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} save_config(cfg) @@ -561,7 +538,6 @@ def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeyp 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: []) - monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) rc = proxy_cli.cmd_start(_args()) assert rc == 1 @@ -578,7 +554,6 @@ def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback( cfg.setdefault("proxy", {})["enabled"] = True cfg["proxy"]["credential_source"] = "bitwarden" cfg["proxy"]["allow_env_fallback"] = True - cfg["proxy"]["fail_on_uncovered_providers"] = False cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} save_config(cfg) @@ -593,7 +568,6 @@ def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback( monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) rc = proxy_cli.cmd_start(_args()) assert rc == 0 diff --git a/tests/test_iron_proxy_e2e.py b/tests/test_iron_proxy_e2e.py index 6be3ac7697f..37c3936c0da 100644 --- a/tests/test_iron_proxy_e2e.py +++ b/tests/test_iron_proxy_e2e.py @@ -166,3 +166,203 @@ def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypat 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() diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 59640921e1c..b0e8c35c555 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -497,10 +497,15 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str # Surface the per-provider proxy tokens under the standard provider env # names so existing SDKs and provider clients work unchanged inside the - # sandbox. Keep the HERMES_PROXY_TOKEN_* aliases for diagnostics. + # 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 diff --git a/website/docs/developer-guide/egress-internals.md b/website/docs/developer-guide/egress-internals.md index e78b3da5b11..f84160788a0 100644 --- a/website/docs/developer-guide/egress-internals.md +++ b/website/docs/developer-guide/egress-internals.md @@ -102,7 +102,6 @@ hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens] hermes egress start -> proxy_cli.cmd_start Pre-checks (refuse-start path): - - proxy.fail_on_uncovered_providers? -> discover_blocked_providers() - credential_source=bitwarden? -> pre-validate access_token_env + project_id -> iron_proxy.start_proxy( refresh_secrets_from_bitwarden=..., @@ -249,19 +248,31 @@ _BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { Also update `_DEFAULT_ALLOWED_HOSTS` so the proxy allows the upstream by default. Run `test_discover_provider_mappings_*` to confirm. -### Adding a new non-bearer provider +### Adding a new header-token provider (x-api-key family) -If the provider uses `x-api-key` / SigV4 / OAuth-from-SDK / etc., iron-proxy's `secrets` transform cannot swap it. Add the env var to `_NON_BEARER_PROVIDERS` so the wizard warns about it. If the provider is LLM-specific enough that you want `fail_on_uncovered_providers: true` to actually block it, also add to `_LLM_SPECIFIC_NON_BEARER_PROVIDERS`. +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_X_API_KEY_PROVIDER", -) - -_LLM_SPECIFIC_NON_BEARER_PROVIDERS: Tuple[str, ...] = ( - ..., - "MY_X_API_KEY_PROVIDER", + "MY_SIGNED_PROVIDER_ACCESS_KEY", ) ``` diff --git a/website/docs/user-guide/egress/iron-proxy.md b/website/docs/user-guide/egress/iron-proxy.md index 82c56764e39..f38cbfa33a8 100644 --- a/website/docs/user-guide/egress/iron-proxy.md +++ b/website/docs/user-guide/egress/iron-proxy.md @@ -80,16 +80,6 @@ proxy: # is unavailable. enforce_on_docker: true - # When true, `hermes egress start` refuses to start if LLM-specific - # non-bearer provider env vars are set (Anthropic native, Azure OpenAI, - # Gemini) — those bypass the proxy's secrets transform and would leak - # real credentials into the sandbox. Defaults to false because the - # false-positive cost (operator has the env set but doesn't actually - # use that provider) is higher than the security cost of a warning. - # See "Uncovered providers" below for the strict tier vs warn tier - # distinction. - fail_on_uncovered_providers: false - # 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 @@ -155,50 +145,29 @@ We also pin `metrics.listen: 127.0.0.1:0` so the daemon's built-in metrics serve 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 -iron-proxy's `secrets` transform only handles `Authorization: Bearer` headers. Providers using `x-api-key`, SigV4, AAD tokens, or custom signatures cannot be proxied — if their env vars are present, the sandbox holds **real credentials** for those providers and the egress isolation guarantee is incomplete for them. - -The wizard and `hermes egress status` always surface uncovered providers in your env. There are two tiers: - -### Strict tier — refuses start when `fail_on_uncovered_providers: true` +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 | |---|---|---| -| `ANTHROPIC_API_KEY` | Anthropic native | x-api-key header, not Bearer | -| `AZURE_OPENAI_API_KEY` | Azure OpenAI | api-key header + optional AAD | -| `GEMINI_API_KEY` | Google AI Studio (Gemini) | x-goog-api-key | +| `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 are LLM-specific names. An operator who has them set is using those providers; a bypass is a real isolation failure. - -### Warn-only tier — surfaced but never blocks - -| Env var | Provider | Reason | -|---|---|---| -| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | AWS Bedrock / SageMaker | SigV4-signed | -| `GOOGLE_APPLICATION_CREDENTIALS` | GCP Vertex AI | gcloud OAuth | -| `GOOGLE_API_KEY` | Google AI Studio | x-goog-api-key OR query param | - -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 + `status` output but don't refuse-start. - -### Operator playbook - -If `hermes egress start` refuses because of a strict-tier env var you don't actually use: - -```bash -unset ANTHROPIC_API_KEY # or whichever one is flagged -hermes egress start -``` - -If you DO use that provider but accept the isolation gap: - -```yaml -# config.yaml -proxy: - fail_on_uncovered_providers: false # default -``` - -Either way, the warning persists in `hermes egress status` until you remove the env var. +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 @@ -258,8 +227,11 @@ hermes egress setup --rotate-tokens # mint fresh tokens for every provider 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 — the one-command - # way to apply config / token changes +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 @@ -447,7 +419,7 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga - 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 (Anthropic native, AWS Bedrock, Azure OpenAI, Gemini). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See [Uncovered providers](#uncovered-providers). +- 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//mem` read from a same-uid attacker would expose them. Out of scope for this layer. ## Failure modes @@ -458,7 +430,6 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga - **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. -- **Strict-tier uncovered provider env var set** — `hermes egress start` refuses with a list of the offending env vars and the `proxy.fail_on_uncovered_providers: false` escape hatch. - **`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. @@ -483,10 +454,6 @@ Or move it into `~/.hermes/.env`. Or switch back to env mode: hermes egress setup --no-bitwarden ``` -### "Refusing to start: provider env vars present that bypass the proxy" - -You have `fail_on_uncovered_providers: true` AND one of `ANTHROPIC_API_KEY` / `AZURE_OPENAI_API_KEY` / `GEMINI_API_KEY` is set in your env. Either unset the offending var, or flip the config flag back to `false` (default) if you accept the isolation gap. - ### "iron-proxy exited immediately" Look at the last 20 lines of `~/.hermes/proxy/iron-proxy.log`. Common causes: @@ -587,10 +554,10 @@ When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-reque ## Limitations (v1) - Docker backend only. Modal, Daytona, and SSH wiring will follow in separate PRs. -- Only bearer-token providers (OpenRouter, OpenAI, Anthropic-via-OR, etc.) are wired through the `secrets` transform out of the box. Providers with custom auth (x-api-key, query params, signatures) bypass the proxy entirely — see [Uncovered providers](#uncovered-providers). +- 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; run `hermes egress start` again, and restart already-running sandboxes after token rotation. +- 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//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.