feat(egress): first-class x-api-key providers + hot reload via management API

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.
This commit is contained in:
teknium1 2026-07-04 02:49:31 -07:00
parent 6ff7e78649
commit 86fcb2fe5f
No known key found for this signature in database
10 changed files with 922 additions and 250 deletions

View file

@ -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
# ``<hermes_home>/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 <key>" 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=<token>`` 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 ``<hermes_home>/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=<token>`` (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",

View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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-")
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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",
)
```

View file

@ -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/<pid>/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/<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.