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