mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue