mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
6ff7e78649
commit
86fcb2fe5f
10 changed files with 922 additions and 250 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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-")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue