From c6dc7c03c355fb3a407c1309aabebb13520c9efd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:38:59 -0700 Subject: [PATCH] Revert "Merge pull request #30179 from NousResearch/feat/iron-proxy" This reverts commit 8790adc4c66fdfdc988824374b4d0c767aa27c74, reversing changes made to fe5054bccfc5128167adafe8235e84c5f807b136. --- agent/proxy_sources/__init__.py | 8 - agent/proxy_sources/iron_proxy.py | 2494 ----------------- .../desktop/src/app/command-palette/index.tsx | 4 +- cli.py | 4 - gateway/run.py | 10 - hermes_cli/commands.py | 6 +- hermes_cli/config.py | 60 - hermes_cli/main.py | 43 +- hermes_cli/proxy_cli.py | 904 ------ hermes_cli/setup.py | 22 - hermes_cli/web_server.py | 27 - scripts/release.py | 1 - tests/cli/test_cli_status_command.py | 21 - tests/gateway/test_unknown_command.py | 30 - tests/hermes_cli/test_commands.py | 1 - tests/hermes_cli/test_web_server.py | 12 - tests/test_iron_proxy.py | 2050 -------------- tests/test_iron_proxy_cli.py | 597 ---- tests/test_iron_proxy_e2e.py | 368 --- tests/tools/test_docker_environment.py | 215 +- tools/environments/docker.py | 523 +--- .../docs/developer-guide/egress-internals.md | 318 --- website/docs/getting-started/quickstart.md | 2 - website/docs/reference/cli-commands.md | 62 - .../docs/reference/environment-variables.md | 17 - website/docs/reference/slash-commands.md | 3 +- website/docs/user-guide/egress/index.md | 10 - website/docs/user-guide/egress/iron-proxy.md | 570 ---- website/sidebars.ts | 10 - 29 files changed, 29 insertions(+), 8363 deletions(-) delete mode 100644 agent/proxy_sources/__init__.py delete mode 100644 agent/proxy_sources/iron_proxy.py delete mode 100644 hermes_cli/proxy_cli.py delete mode 100644 tests/test_iron_proxy.py delete mode 100644 tests/test_iron_proxy_cli.py delete mode 100644 tests/test_iron_proxy_e2e.py delete mode 100644 website/docs/developer-guide/egress-internals.md delete mode 100644 website/docs/user-guide/egress/index.md delete mode 100644 website/docs/user-guide/egress/iron-proxy.md diff --git a/agent/proxy_sources/__init__.py b/agent/proxy_sources/__init__.py deleted file mode 100644 index 34af31ca6fb..00000000000 --- a/agent/proxy_sources/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Egress proxy integrations. - -Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts -outbound traffic from remote terminal sandboxes and swaps proxy tokens -for real upstream credentials at the network edge. - -Design notes live in :mod:`agent.proxy_sources.iron_proxy`. -""" diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py deleted file mode 100644 index dae910248d5..00000000000 --- a/agent/proxy_sources/iron_proxy.py +++ /dev/null @@ -1,2494 +0,0 @@ -"""iron-proxy (`ironsh/iron-proxy`) integration for credential-injecting egress control. - -Why ---- - -Remote terminal sandboxes (Docker, Modal, SSH) currently see real upstream -API credentials. A prompt-injected agent inside one of these sandboxes can -``cat ~/.config/openrouter/auth.json`` or ``printenv | grep -i key`` and -exfiltrate them. - -iron-proxy is a TLS-intercepting egress firewall (Apache-2.0, Go binary, by -ironsh). It sits between the sandbox and the internet, enforces a default-deny -allowlist on outbound hosts, and *swaps proxy tokens for real credentials* -on the way out. The sandbox only ever holds opaque proxy tokens — leaking -them is useless, since they only work behind the configured trusted proxy -boundary (the CA private key and proxy endpoint integrity are part of that -boundary: if traffic can be redirected to attacker-controlled proxy -infrastructure, the guarantee no longer holds). - -Design summary --------------- - -* The ``iron-proxy`` binary is auto-installed into ``/bin/iron-proxy`` - on first use. Hermes pins one upstream version (``_IRON_PROXY_VERSION``) - and downloads the matching tar.gz from the official GitHub Releases page, - verifying the SHA-256 against the release's ``checksums.txt``. - -* A long-lived CA at ``/proxy/ca.{crt,key}`` is generated on - first ``hermes egress setup``. Sandboxes trust this CA so iron-proxy can - terminate TLS and rewrite headers. - -* The proxy config lives at ``/proxy/proxy.yaml``. It enumerates - the per-provider allowlists and the ``secrets`` transform that does the - Authorization-header swap. - -* Token mappings (proxy token -> real credential lookup) live alongside the - config. The real credential is **never** written to the config — iron-proxy - reads it from its own environment via ``{type: env, var: NAME}``. When - Bitwarden Secrets Manager is configured, the real value is pulled there - at proxy startup instead. - -* The proxy runs as a managed subprocess (``hermes egress start``), pidfile - at ``/proxy/iron-proxy.pid``. Daemon output (including - per-request records on v0.39) goes to ``/proxy/iron-proxy.log``; - ``audit.log`` is pre-created but reserved for a future pin that supports - ``log.audit_path``. - -* Failures (binary missing, port collision, bad config) emit a one-line - warning and do *not* block agent startup. The Docker backend refuses to - start a sandbox with the proxy enabled-but-down, with a clear error. - -This module is intentionally subprocess-driven rather than depending on any -iron-proxy Python bindings — a single cross-platform binary is easier to -lazy-install than a wheels-with-extension dependency, and we keep maintenance -to a "bump the pinned version" loop. -""" - -from __future__ import annotations - -import hashlib -import ipaddress -import json -import logging -import os -import platform -import shutil -import signal -import stat -import subprocess -import tarfile -import tempfile -import threading -import time -import urllib.error -import urllib.request -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional, Tuple - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Configuration constants -# --------------------------------------------------------------------------- - -# Pinned upstream version. Bump in a follow-up PR — never auto-resolve "latest" -# because upstream YAML schema is allowed to change between releases and we -# want updates to be deliberate. -_IRON_PROXY_VERSION = "0.39.0" - -_IRON_PROXY_RELEASE_BASE = ( - f"https://github.com/ironsh/iron-proxy/releases/download/v{_IRON_PROXY_VERSION}" -) -_IRON_PROXY_CHECKSUM_NAME = "checksums.txt" -# Detached signature for checksums.txt + the signing public key, both shipped on -# the release. Used for optional GPG verification of the release channel -# (maxpetrusenko P1): SHA-256 only protects the archive if checksums.txt itself -# came from an uncompromised channel; verifying its signature closes that gap. -_IRON_PROXY_CHECKSUM_SIG_NAME = "checksums.txt.asc" -_IRON_PROXY_PUBKEY_NAME = "public-key.asc" - -# How long to wait for HTTP downloads and subprocess interactions, in seconds. -_DOWNLOAD_TIMEOUT = 120 # binary is ~16MB -_RUN_TIMEOUT = 30 -_STARTUP_GRACE_SECONDS = 5 - -# Management (operator) API. iron-proxy v0.39 ships an authenticated -# loopback HTTP endpoint (``management.listen`` + ``management.api_key_env``) -# whose ``POST /v1/reload`` re-reads proxy.yaml and atomically swaps the -# transform pipeline in-place — no restart, no dropped connections. We -# always enable it on generated configs: it binds loopback only and every -# request needs the bearer key below. ``hermes egress reload`` is the -# client. -# -# The key is minted at setup time, stored at -# ``/proxy/management.token`` (0600), and injected into the -# daemon's env under this name at start. v0.39 validates at startup that -# the named env var is non-empty when management.listen is set. -_MGMT_API_KEY_ENV = "HERMES_IRON_PROXY_MGMT_KEY" -# The management listener binds loopback at tunnel_port + 2 (tunnel_port -# is CONNECT/MITM, +1 is the plain-HTTP forward listener). -_MGMT_PORT_OFFSET = 2 -_MGMT_RELOAD_TIMEOUT = 15 - -# Default listen ports. HTTPS_PROXY semantics use a single CONNECT tunnel, -# so we expose only the tunnel listener for v1 — no need to put the sandbox -# DNS at the iron-proxy IP. This greatly simplifies wiring. -_DEFAULT_TUNNEL_PORT = 9090 - -# Hosts allowed by default for AI inference traffic. Anything else is 403'd. -_DEFAULT_ALLOWED_HOSTS: Tuple[str, ...] = ( - "openrouter.ai", - "*.openrouter.ai", - "api.openai.com", - "api.anthropic.com", - "generativelanguage.googleapis.com", - "api.x.ai", - "api.mistral.ai", - "api.groq.com", - "api.together.xyz", - "api.deepseek.com", - "inference.nousresearch.com", -) - -# Provider env-var name -> upstream host (or list of hosts) on which the -# 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",), - "GROQ_API_KEY": ("api.groq.com",), - "TOGETHER_API_KEY": ("api.together.xyz",), - "DEEPSEEK_API_KEY": ("api.deepseek.com",), - "MISTRAL_API_KEY": ("api.mistral.ai",), - "XAI_API_KEY": ("api.x.ai",), - "NOUS_API_KEY": ("inference.nousresearch.com",), -} - - -# 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". -# -# ``aliases`` are interchangeable env-var names for the SAME upstream -# credential (Hermes' auth.py keys Google on both GEMINI_API_KEY and -# GOOGLE_API_KEY). Aliased names MUST collapse into a single mapping: -# every rule carries ``require: true``, and two require-rules on the same -# host reject each other's requests (each rule whose own token isn't -# present returns ActionReject). The sandbox receives the minted token -# under the canonical name AND every alias so SDKs reading either work. -_HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = { - # Anthropic native: x-api-key. Authorization is also matched so an - # SDK sending the token as a Bearer (OAuth-style) still swaps. - "ANTHROPIC_API_KEY": { - "hosts": ("api.anthropic.com",), - "match_headers": ("x-api-key", "Authorization"), - "aliases": (), - }, - # Azure OpenAI: api-key header (AAD bearer flows use Authorization). - "AZURE_OPENAI_API_KEY": { - "hosts": ( - "*.openai.azure.com", - "*.cognitiveservices.azure.com", - "*.services.ai.azure.com", - ), - "match_headers": ("api-key", "Authorization"), - "aliases": (), - }, - # Google AI Studio (Gemini): x-goog-api-key header; the SDKs that pass - # ``?key=`` as a query param are covered by match_query, which - # scans every query parameter for the token value. - "GEMINI_API_KEY": { - "hosts": ("generativelanguage.googleapis.com",), - "match_headers": ("x-goog-api-key",), - "aliases": ("GOOGLE_API_KEY",), - }, -} - - -# Providers whose env-var names we recognize but whose auth genuinely cannot -# be swapped by a static header/query replacement (SigV4 request signing, -# OAuth tokens minted by an SDK from a service-account file). Presence is -# surfaced as a warning at setup/status time — these are generic cloud creds -# that are usually present for unrelated tooling (terraform, gcloud, aws-cli), -# so they never block the proxy from starting. -# -# 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, ...] = ( - # AWS Bedrock / SageMaker: SigV4-signed requests. - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - # GCP Vertex AI: OAuth bearer minted by the SDK from a service-account - # file, not a static env key. - "GOOGLE_APPLICATION_CREDENTIALS", -) - - -# Default SSRF-protection deny list applied to the proxy's outbound traffic. -# Mirrors the public docs promise ("cloud metadata IPs are refused by default -# regardless of allowlist"). Tests / dev setups that need loopback can pass -# an explicit override (e.g. [] to disable, or a smaller subset). -_DEFAULT_UPSTREAM_DENY_CIDRS: Tuple[str, ...] = ( - "127.0.0.0/8", # IPv4 loopback - "::1/128", # IPv6 loopback - "169.254.0.0/16", # IPv4 link-local incl. AWS/GCP/Azure IMDS - "fe80::/10", # IPv6 link-local - "10.0.0.0/8", # RFC1918 - "172.16.0.0/12", # RFC1918 - "192.168.0.0/16", # RFC1918 - "fc00::/7", # IPv6 ULA - # IPv4-mapped IPv6 (``::ffff:0:0/96``) covers the dual-stack case - # where an upstream resolves to e.g. ``::ffff:169.254.169.254`` and - # the kernel hands the v4-mapped form to the socket — that would - # otherwise be a clean SSRF bypass to IMDS through the v6 path. - "::ffff:0:0/96", - # RFC6598 / CGNAT — used by AWS VPC for shared services, K8s pod - # networks, many cloud overlays. Not strictly RFC1918 but operators - # universally want it denied for the same reasons. - "100.64.0.0/10", - # RFC2544 benchmark range — rare in practice but occasionally used - # for internal services and never legitimate as an upstream. - "198.18.0.0/15", -) - - -# Min env vars the iron-proxy subprocess actually needs. Everything else -# is stripped — see ``_build_proxy_subprocess_env`` for the rationale. -_PROXY_SUBPROCESS_ENV_ALLOWLIST: Tuple[str, ...] = ( - "PATH", - "HOME", - "TMPDIR", - "TZ", - "LANG", - "LC_ALL", - "LC_CTYPE", - "NO_COLOR", - "SSL_CERT_DIR", - "SSL_CERT_FILE", - "SYSTEMROOT", # Windows - "USERPROFILE", # Windows -) - - -# Env vars that must be stripped from the subprocess env even if they're on -# the allowlist or named in mappings — these would either recurse the proxy -# back through itself or send its traffic through a corporate proxy. -_PROXY_SUBPROCESS_ENV_STRIP: Tuple[str, ...] = ( - "HTTPS_PROXY", "https_proxy", - "HTTP_PROXY", "http_proxy", - "ALL_PROXY", "all_proxy", - "NO_PROXY", "no_proxy", -) - - -# SIGKILL doesn't exist on Windows. We fall back to SIGTERM there, which the -# OS treats as a hard terminate via TerminateProcess() — equivalent semantics. -_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) - - -# Cached ``iron-proxy --version`` output keyed by binary path. ``get_status`` -# is invoked per Docker-container-create; the version string is constant for -# a given binary so a one-shot subprocess call is plenty. -_VERSION_CACHE: Dict[str, str] = {} - - -# --------------------------------------------------------------------------- -# Public dataclasses -# --------------------------------------------------------------------------- - - -@dataclass -class ProxyStatus: - """Snapshot of the iron-proxy installation + runtime state.""" - - enabled: bool = False - binary_path: Optional[Path] = None - binary_version: Optional[str] = None - config_path: Optional[Path] = None - ca_cert_path: Optional[Path] = None - pid: Optional[int] = None - listening: bool = False - tunnel_port: int = _DEFAULT_TUNNEL_PORT - warnings: List[str] = field(default_factory=list) - - @property - def installed(self) -> bool: - return self.binary_path is not None and self.binary_path.exists() - - @property - def configured(self) -> bool: - return ( - self.config_path is not None - and self.config_path.exists() - and self.ca_cert_path is not None - and self.ca_cert_path.exists() - ) - - -@dataclass -class TokenMapping: - """Map a sandbox-visible proxy token to a real upstream credential lookup. - - ``real_env_name`` is the env-var name iron-proxy reads at egress time. - 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, ...] = () - - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - - -def _hermes_bin_dir() -> Path: - from hermes_constants import get_hermes_home - - return get_hermes_home() / "bin" - - -def _proxy_state_dir_ro() -> Path: - """Return the proxy state dir without creating it. - - Read-only callers (status probes, pidfile reads, version queries) use - this — there's no reason to materialize ``~/.hermes/proxy/`` just to - check whether a pidfile exists. - """ - from hermes_constants import get_hermes_home - - return get_hermes_home() / "proxy" - - -def _proxy_state_dir() -> Path: - """Return the proxy state dir, creating it with 0o700 if absent. - - Writable callers (CA gen, config write, mappings write, start_proxy) - use this. We force 0o700 — the dir holds the CA signing key, audit - log, and pidfile, so traversal by other local users is undesirable. - The chmod is unconditional so a pre-existing dir with a slack umask - gets tightened on first access. - """ - d = _proxy_state_dir_ro() - d.mkdir(parents=True, exist_ok=True) - try: - d.chmod(0o700) - except OSError: - # On Windows the chmod is a no-op for POSIX modes; on shared - # filesystems we may not own the dir. Don't fail here — the - # individual files still get explicit perms. - pass - return d - - -def _platform_binary_name() -> str: - return "iron-proxy.exe" if platform.system() == "Windows" else "iron-proxy" - - -def _platform_asset_name() -> str: - """Map (uname, arch) → upstream release asset filename. - - iron-proxy ships ``iron-proxy___.tar.gz``. - Windows builds aren't published upstream as of v0.39.0; we raise a - clear error for callers on Windows. - """ - - system = platform.system() - machine = platform.machine().lower() - - if system == "Linux": - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return f"iron-proxy_{_IRON_PROXY_VERSION}_linux_{arch}.tar.gz" - if system == "Darwin": - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return f"iron-proxy_{_IRON_PROXY_VERSION}_darwin_{arch}.tar.gz" - if system == "Windows": - raise RuntimeError( - "iron-proxy does not ship native Windows binaries as of " - f"v{_IRON_PROXY_VERSION}. Run the proxy on a Linux/macOS host, " - "or inside WSL." - ) - - raise RuntimeError( - f"Unsupported platform for iron-proxy auto-install: {system} {machine}" - ) - - -# --------------------------------------------------------------------------- -# Binary discovery + lazy install -# --------------------------------------------------------------------------- - - -def find_iron_proxy(*, install_if_missing: bool = False) -> Optional[Path]: - """Return a path to a usable ``iron-proxy`` binary, or None. - - Resolution order: - 1. ``/bin/iron-proxy`` (our managed copy — preferred) - 2. ``shutil.which("iron-proxy")`` (system PATH) - - When ``install_if_missing`` is True and neither resolves, calls - :func:`install_iron_proxy` to download and verify the pinned version. - """ - - managed = _hermes_bin_dir() / _platform_binary_name() - if managed.exists() and os.access(managed, os.X_OK): - return managed - - system = shutil.which("iron-proxy") - if system: - return Path(system) - - if install_if_missing: - try: - return install_iron_proxy() - except Exception as exc: # noqa: BLE001 — never block startup - logger.warning("iron-proxy auto-install failed: %s", exc) - return None - return None - - -def install_iron_proxy(*, force: bool = False) -> Path: - """Download, verify, and install the pinned ``iron-proxy`` binary. - - Returns the path to the installed executable. Raises on any failure - (network, checksum, extraction). Callers in the auto-install path catch - these; the user-facing ``hermes proxy install`` surface lets them - propagate so the wizard can show a clear error. - """ - - bin_dir = _hermes_bin_dir() - bin_dir.mkdir(parents=True, exist_ok=True) - target = bin_dir / _platform_binary_name() - - if target.exists() and not force: - return target - - asset_name = _platform_asset_name() - asset_url = f"{_IRON_PROXY_RELEASE_BASE}/{asset_name}" - checksum_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_CHECKSUM_NAME}" - - with tempfile.TemporaryDirectory(prefix="hermes-iron-proxy-") as tmpdir: - tmp = Path(tmpdir) - archive_path = tmp / asset_name - checksum_path = tmp / _IRON_PROXY_CHECKSUM_NAME - - logger.info("Downloading %s", asset_url) - _http_download(asset_url, archive_path) - _http_download(checksum_url, checksum_path) - - # Defense-in-depth (maxpetrusenko P1): verify the GPG signature of - # checksums.txt before trusting it. The archive download honors ambient - # proxy env (urllib), so a compromised channel could serve a matching - # binary + checksums pair; the detached signature + pinned public key - # close that release-channel tamper gap. Best-effort: if gpg or the - # signature assets aren't available we log and fall back to the SHA-256 - # check alone rather than hard-failing offline installs. - _verify_checksums_signature(tmp, checksum_path) - - expected = _expected_sha256(checksum_path, asset_name) - actual = _sha256_file(archive_path) - if expected.lower() != actual.lower(): - raise RuntimeError( - f"Checksum mismatch for {asset_name}: " - f"expected {expected}, got {actual}" - ) - - with tarfile.open(archive_path, "r:gz") as tf: - member = _pick_tar_member(tf, _platform_binary_name()) - # PEP 706 data filter — strips ownership/mode replay (we set - # chmod explicitly below) AND rejects symlink/hardlink members - # that escape the extraction dir. Required on 3.12+ to silence - # the deprecation warning and on 3.14+ to opt into the - # tarbomb-rejecting default. - try: - tf.extract(member, tmp, filter="data") # noqa: S202 - except TypeError: - # Python < 3.12 — filter kw didn't exist yet; the - # _pick_tar_member sanitization already rejects path - # traversal so this is acceptable. - tf.extract(member, tmp) # noqa: S202 - extracted = tmp / member.name - - # Stage into the final directory then atomically rename so the new - # binary is never visible half-written. - fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".iron-proxy_") - os.close(fd) - shutil.copy2(extracted, staged) - os.chmod( - staged, - stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR - | stat.S_IRGRP | stat.S_IXGRP - | stat.S_IROTH | stat.S_IXOTH, - ) - os.replace(staged, target) - - # Invalidate the version cache so a freshly-installed binary - # re-probes ``--version`` on the next ``get_status()`` call instead - # of returning the pre-upgrade string. Long-lived processes that - # bump the pinned version via ``force=True`` need this. - _VERSION_CACHE.pop(str(target), None) - - logger.info("Installed iron-proxy %s at %s", _IRON_PROXY_VERSION, target) - return target - - -def _http_download(url: str, dest: Path) -> None: - req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"}) - try: - with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp: # noqa: S310 - with open(dest, "wb") as f: - shutil.copyfileobj(resp, f) - except urllib.error.URLError as exc: - raise RuntimeError(f"Failed to download {url}: {exc}") from exc - - -def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool: - """Best-effort GPG verification of ``checksums.txt`` (maxpetrusenko P1). - - Downloads the detached signature (``checksums.txt.asc``) and the release - signing key (``public-key.asc``), imports the key into an ephemeral - keyring, and verifies the signature over ``checksum_path``. - - Returns True when the signature is verified. Returns False (with a warning) - when verification is unavailable — ``gpg`` not installed, or the signature / - public-key assets are missing from the release. Raises RuntimeError ONLY - when verification actively FAILS (a present-but-bad signature), which is a - tamper signal we must not ignore. - - Rationale for graceful degradation on "unavailable": the SHA-256 check - against ``checksums.txt`` remains in force regardless, and many install - hosts (CI, minimal containers) won't have gpg. We harden when we can and - never make gpg a hard dependency for a working install. - """ - gpg = shutil.which("gpg") - if not gpg: - logger.warning( - "gpg not found on PATH — skipping iron-proxy release-signature " - "verification (SHA-256 checksum check still enforced)." - ) - return False - - sig_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_CHECKSUM_SIG_NAME}" - pubkey_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_PUBKEY_NAME}" - sig_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME - pubkey_path = tmp / _IRON_PROXY_PUBKEY_NAME - - try: - _http_download(sig_url, sig_path) - _http_download(pubkey_url, pubkey_path) - except RuntimeError as exc: - logger.warning( - "iron-proxy release signature assets unavailable (%s) — skipping " - "GPG verification (SHA-256 checksum check still enforced).", exc, - ) - return False - - # Ephemeral keyring so we never touch the user's real GPG home. - gnupg_home = tmp / "gnupg" - gnupg_home.mkdir(mode=0o700, exist_ok=True) - base_cmd = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"] - - imp = subprocess.run( # noqa: S603 — gpg path from trusted PATH lookup - [*base_cmd, "--import", str(pubkey_path)], - capture_output=True, timeout=60, - ) - if imp.returncode != 0: - logger.warning( - "Could not import iron-proxy signing key — skipping GPG " - "verification (SHA-256 still enforced): %s", - imp.stderr.decode("utf-8", "replace")[:200], - ) - return False - - verify = subprocess.run( # noqa: S603 - [*base_cmd, "--verify", str(sig_path), str(checksum_path)], - capture_output=True, timeout=60, - ) - if verify.returncode != 0: - # A present signature that does NOT verify is a tamper signal — fail hard. - raise RuntimeError( - "iron-proxy checksums.txt failed GPG signature verification — " - "refusing to install (possible release-channel tampering). " - f"gpg: {verify.stderr.decode('utf-8', 'replace')[:300]}" - ) - logger.info("Verified iron-proxy checksums.txt GPG signature.") - return True - - -def _expected_sha256(checksum_file: Path, asset_name: str) -> str: - """Parse the standard ``sha256sum`` output: `` ``.""" - - text = checksum_file.read_text(encoding="utf-8", errors="replace") - for line in text.splitlines(): - parts = line.strip().split() - if len(parts) >= 2 and parts[-1] == asset_name: - return parts[0] - raise RuntimeError( - f"No checksum entry for {asset_name} in {checksum_file.name}" - ) - - -def _sha256_file(path: Path) -> str: - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(65536), b""): - h.update(chunk) - return h.hexdigest() - - -def _pick_tar_member(tf: tarfile.TarFile, binary_name: str) -> tarfile.TarInfo: - """Find the binary inside the upstream tar. - - iron-proxy's archive is typically flat (binary at root) but we tolerate - a top-level directory. Members must be regular files with a leaf name - matching ``binary_name``, no absolute paths, and no ``..`` traversal. - """ - - candidates: List[tarfile.TarInfo] = [] - for member in tf.getmembers(): - if not member.isfile(): - continue - if member.name.startswith("/") or ".." in Path(member.name).parts: - continue - if Path(member.name).name == binary_name: - candidates.append(member) - if not candidates: - raise RuntimeError( - f"Could not find {binary_name} inside downloaded archive " - f"(members: {[m.name for m in tf.getmembers()[:5]]}...)" - ) - candidates.sort(key=lambda m: len(m.name)) - return candidates[0] - - -def iron_proxy_version(binary: Path) -> str: - """Return ``iron-proxy --version`` output, stripped. Empty on failure. - - Cached by binary path: ``get_status`` is called per Docker container - create, but the version string is constant for a given binary. A - single subprocess invocation is plenty. - """ - - key = str(binary) - cached = _VERSION_CACHE.get(key) - if cached is not None: - return cached - - try: - # Build a minimal env: only PATH, HOME, and locale vars. - # The version probe is a one-shot subprocess — forwarding - # the full host env (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) - # to a PATH-resolved or unverified binary is an unnecessary - # credential leak. Reuse the same allowlist the daemon - # subprocess uses (see _build_proxy_subprocess_env). - minimal_env: Dict[str, str] = {} - parent = os.environ - for name in _PROXY_SUBPROCESS_ENV_ALLOWLIST: - if name in parent: - minimal_env[name] = parent[name] - # The S603 warning is legitimate for the PATH-fallback case - # (find_iron_proxy → shutil.which), but --version with a - # scrubbed env is safe regardless of binary provenance. - res = subprocess.run( # noqa: S603 - [str(binary), "--version"], - capture_output=True, - text=True, - timeout=_RUN_TIMEOUT, - env=minimal_env, - ) - except (OSError, subprocess.TimeoutExpired): - return "" - out = (res.stdout or res.stderr or "").strip() - # Don't cache empty output — that would poison ``hermes egress - # status`` for the lifetime of the process if the first probe hit a - # corrupt binary or a flag-rename in a newer upstream. Re-probe on - # the next call instead. - if out: - _VERSION_CACHE[key] = out - return out - - -# --------------------------------------------------------------------------- -# CA cert generation -# --------------------------------------------------------------------------- - - -def ensure_ca_cert(*, force: bool = False) -> Tuple[Path, Path]: - """Generate (or return existing) iron-proxy CA cert + key. - - Uses the host's ``openssl`` binary. We don't try to bind to a Python - crypto library — openssl is universally available on the platforms we - support, and it sidesteps cryptography-package licensing/distribution - surface. - """ - - state = _proxy_state_dir() - ca_crt = state / "ca.crt" - ca_key = state / "ca.key" - - if ca_crt.exists() and ca_key.exists() and not force: - return ca_crt, ca_key - - if shutil.which("openssl") is None: - raise RuntimeError( - "openssl not found on PATH. Install OpenSSL (apt: `openssl`, " - "brew: `openssl`) to generate the iron-proxy CA cert." - ) - - # 10-year cert. iron-proxy mints short-lived leaf certs from this CA, - # so the CA itself only rotates when the user explicitly forces it. - with tempfile.TemporaryDirectory(prefix="hermes-proxy-ca-") as tmpdir: - tmp = Path(tmpdir) - tmp_key = tmp / "ca.key" - tmp_crt = tmp / "ca.crt" - - subprocess.run( # noqa: S603 — openssl path is trusted PATH lookup - ["openssl", "genrsa", "-out", str(tmp_key), "4096"], - check=True, - capture_output=True, - timeout=60, - ) - subprocess.run( # noqa: S603 - [ - "openssl", "req", "-x509", "-new", "-nodes", - "-key", str(tmp_key), - "-sha256", "-days", "3650", - "-subj", "/CN=hermes iron-proxy CA", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign", - "-out", str(tmp_crt), - ], - check=True, - capture_output=True, - timeout=60, - ) - - # Move into place with private permissions. CRITICAL: the key - # has to be created with 0o600 from the very first byte — a - # ``shutil.copy2`` followed by ``os.chmod`` leaves a TOCTOU window - # where the private key is world-readable on multi-user hosts. - key_bytes = tmp_key.read_bytes() - crt_bytes = tmp_crt.read_bytes() - - # Stage with explicit 0o600, then atomically rename into place. - # O_NOFOLLOW guards against a symlink at ca_key (defence-in-depth - # — the state dir is 0o700-owned but a malicious local user with - # the same uid could pre-create one). - key_staged = ca_key.with_suffix(ca_key.suffix + ".staged") - open_flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - # O_NOFOLLOW exists on POSIX; on Windows we just rely on the - # default semantics. - if hasattr(os, "O_NOFOLLOW"): - open_flags |= os.O_NOFOLLOW - # Best-effort: pre-unlink any existing staged file so the open - # with O_CREAT is always against a fresh inode. - try: - key_staged.unlink() - except FileNotFoundError: - pass - fd = os.open(str(key_staged), open_flags, 0o600) - try: - with os.fdopen(fd, "wb") as f: - f.write(key_bytes) - except Exception: - try: - os.close(fd) - except OSError: - pass - raise - os.replace(key_staged, ca_key) - - # Cert is public — 0o644 is fine and matches typical PEM layout. - ca_crt.write_bytes(crt_bytes) - os.chmod(ca_crt, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - - logger.info("Generated iron-proxy CA at %s", ca_crt) - return ca_crt, ca_key - - -# --------------------------------------------------------------------------- -# Proxy config + token mapping generation -# --------------------------------------------------------------------------- - - -def mint_proxy_token(prefix: str = "hermes-proxy") -> str: - """Mint a fresh opaque token to hand to the sandbox. - - The token has no internal structure beyond a recognizable prefix — - iron-proxy matches on exact equality. We use a 128-bit random suffix - (32 hex chars from a SHA-256 of 32 bytes of os.urandom). At that - entropy the birthday-bound collision probability is below 2^-64 for - up to 2^32 tokens, which is plenty for a proxy-scoped namespace. - """ - - return f"{prefix}-{hashlib.sha256(os.urandom(32)).hexdigest()[:32]}" - - -def _management_token_path() -> Path: - return _proxy_state_dir() / "management.token" - - -def ensure_management_token(*, force: bool = False) -> str: - """Return the management-API bearer key, minting it on first call. - - Stored at ``/proxy/management.token`` with 0600 perms. - The daemon receives it via the ``HERMES_IRON_PROXY_MGMT_KEY`` env var - (named in the generated config's ``management.api_key_env``); - ``hermes egress reload`` reads the same file to authenticate. - """ - - p = _management_token_path() - if not force and p.exists(): - try: - existing = p.read_text(encoding="utf-8").strip() - if existing: - return existing - except OSError: - pass - token = mint_proxy_token(prefix="hermes-mgmt") - fd = os.open( - str(p), - os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), - 0o600, - ) - try: - os.fchmod(fd, 0o600) - except (OSError, AttributeError): - pass - try: - os.write(fd, token.encode("utf-8")) - finally: - os.close(fd) - return token - - -def _read_management_token() -> Optional[str]: - p = _proxy_state_dir_ro() / "management.token" - try: - token = p.read_text(encoding="utf-8").strip() - except OSError: - return None - return token or None - - -def _read_management_listen_from_config( - config_path: Optional[Path] = None, -) -> Optional[Tuple[str, int]]: - """Return ``(host, port)`` of the management listener, if configured.""" - - cfg = config_path or (_proxy_state_dir_ro() / "proxy.yaml") - if not cfg.exists(): - return None - try: - import yaml - except ImportError: - return None - try: - data = yaml.safe_load(cfg.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError): - return None - listen = ((data or {}).get("management") or {}).get("listen") or "" - if not isinstance(listen, str) or ":" not in listen: - return None - host, _, port_s = listen.rpartition(":") - try: - port = int(port_s) - except ValueError: - return None - return (host or "127.0.0.1", port) - - -def reload_proxy() -> bool: - """Hot-reload the running daemon's ruleset via the management API. - - POSTs to ``/v1/reload`` on the loopback management listener; the daemon - re-reads proxy.yaml and atomically swaps the transform pipeline — - validation failures leave the running config untouched (HTTP 422). - - Returns True on a successful reload. Raises ``RuntimeError`` with an - actionable message when the daemon isn't running, the config predates - management-API support (no ``management`` block → restart required), - or the reload is rejected. - """ - - pid = _read_pid() - if not pid or not _pid_alive(pid): - raise RuntimeError( - "iron-proxy is not running — nothing to reload. " - "Run `hermes egress start`." - ) - mgmt = _read_management_listen_from_config() - if mgmt is None: - raise RuntimeError( - "The generated proxy.yaml has no management listener (written " - "before reload support). Re-run `hermes egress setup` and use " - "`hermes egress restart` this one time." - ) - token = _read_management_token() - if not token: - raise RuntimeError( - "management.token is missing — re-run `hermes egress setup`, " - "then `hermes egress restart`." - ) - - import urllib.error - import urllib.request - - host, port = mgmt - req = urllib.request.Request( - f"http://{host}:{port}/v1/reload", - method="POST", - headers={"Authorization": f"Bearer {token}"}, - data=b"", - ) - try: - with urllib.request.urlopen(req, timeout=_MGMT_RELOAD_TIMEOUT) as resp: - if resp.status == 200: - return True - raise RuntimeError( - f"management API returned unexpected status {resp.status}" - ) - except urllib.error.HTTPError as exc: - body = "" - try: - body = exc.read().decode("utf-8", errors="replace")[:500] - except OSError: - pass - if exc.code == 422: - raise RuntimeError( - f"iron-proxy rejected the new config (validation failed; " - f"the running ruleset is unchanged): {body}" - ) from exc - if exc.code == 401: - raise RuntimeError( - "management API rejected our key (401). The running " - "daemon was started with a different management.token — " - "run `hermes egress restart`." - ) from exc - raise RuntimeError( - f"management reload failed (HTTP {exc.code}): {body}" - ) from exc - except (urllib.error.URLError, OSError) as exc: - # A daemon started from a pre-management config is alive but has - # no listener on the management port. - raise RuntimeError( - f"could not reach the management API at {host}:{port} ({exc}). " - "If the daemon was started before reload support, run " - "`hermes egress restart` once." - ) from exc - - -def _default_http_listen(tunnel_port: int) -> List[str]: - """Build the single host:port bind the proxy should listen on. - - iron-proxy v0.39 supports exactly ONE ``proxy.http_listen`` bind per - daemon process, so this returns a one-element list and the choice of - host matters: - - * **Linux:** bind the docker bridge gateway (``172.17.0.1`` by - default). Sandboxes reach the proxy via - ``host.docker.internal:host-gateway``, which Docker resolves to - exactly this bridge gateway IP on Linux — a loopback-only bind is - unreachable from inside containers there. The bridge IP is still - host-local (it's an address on the host's ``docker0`` interface), - so host-side tooling and the status probe can reach it too. When - no docker bridge is detected (docker not installed / not started), - fall back to loopback — there are no sandboxes to serve in that - state, and the operator gets a warning. - * **macOS / Windows Docker Desktop:** ``host.docker.internal`` - resolves via VPNkit to the host, so a loopback bind is reachable - from containers and is the least-exposed choice. - - We never bind ``0.0.0.0`` — that would expose the proxy (and, with a - leaked sandbox token, the user's API quota) to anyone on the local - network. The bridge-gateway bind is reachable by other containers - on the default bridge network, which is unavoidable given v0.39's - single-bind limit; requests still require a minted proxy token and - an allowlisted upstream. - """ - - if platform.system() == "Linux": - bridge_ip = _detect_docker_bridge_ip() - if bridge_ip and bridge_ip != "127.0.0.1": - return [f"{bridge_ip}:{tunnel_port}"] - logger.warning( - "No docker bridge (docker0) detected — binding iron-proxy to " - "loopback only. Docker sandboxes will NOT be able to reach " - "the proxy until it is restarted with docker running." - ) - return [f"127.0.0.1:{tunnel_port}"] - - -def _detect_docker_bridge_ip() -> Optional[str]: - """Return the docker0 bridge IPv4, if present, else None. - - Best-effort: we try ``ip -4 addr show docker0`` first. Anything that - fails, doesn't parse as a strict IPv4, or parses as an address we - must NOT bind to (unspecified, loopback, multicast, reserved, public) - returns None — callers handle that as "no bridge bind". - - SECURITY: a hostile ``ip`` shim earlier on the operator's PATH used - to be able to inject ``0.0.0.0`` here and re-open INADDR_ANY binding - that the rest of the bind-policy work explicitly closed. We - validate via :mod:`ipaddress` and reject anything that isn't - plausibly a docker bridge IP (private + non-special). - """ - - candidate: Optional[str] = None - try: - res = subprocess.run( # noqa: S603 — ip is a system binary - ["ip", "-4", "-o", "addr", "show", "docker0"], - capture_output=True, text=True, timeout=2, - ) - if res.returncode == 0: - for line in res.stdout.splitlines(): - parts = line.split() - # Expected: ": docker0 inet 172.17.0.1/16 ..." - for i, tok in enumerate(parts): - if tok == "inet" and i + 1 < len(parts): - candidate = parts[i + 1].split("/")[0] - break - if candidate is not None: - break - except (OSError, subprocess.TimeoutExpired): - return None - - if not candidate: - return None - - # Stdlib validation: rejects garbage strings AND special-purpose - # addresses that must not be used as a bind target. - try: - addr = ipaddress.IPv4Address(candidate) - except (ipaddress.AddressValueError, ValueError): - return None - # Reject: - # - 0.0.0.0 / INADDR_ANY (is_unspecified) - # - 127.0.0.0/8 (is_loopback — already in deny list) - # - 224.0.0.0/4 (is_multicast) - # - 240.0.0.0/4 (is_reserved) - # - 169.254.0.0/16 (is_link_local — IMDS range, never docker0) - # - global / public IPs (is_global — docker0 must be RFC1918) - if ( - addr.is_unspecified - or addr.is_loopback - or addr.is_multicast - or addr.is_reserved - or addr.is_link_local - or addr.is_global - ): - logger.warning( - "Refusing suspicious docker bridge IP %s reported by `ip`; " - "skipping bridge bind.", candidate, - ) - return None - - return str(addr) - - -def build_proxy_config( - *, - mappings: List[TokenMapping], - ca_cert: Path, - ca_key: Path, - tunnel_port: int = _DEFAULT_TUNNEL_PORT, - audit_log: Optional[Path] = None, - allowed_hosts: Optional[List[str]] = None, - upstream_deny_cidrs: Optional[List[str]] = None, - http_listen: Optional[List[str]] = None, -) -> Dict: - """Build the iron-proxy YAML config (as a dict) for a given mapping set. - - The dict is YAML-serializable via ``yaml.safe_dump``. iron-proxy reads - real secrets from its OWN environment via ``source: {type: env, var: ...}``; - the sandbox never sees them. - - Bind policy: the sandbox-facing listeners (``tunnel_listen`` on - ``tunnel_port``, plain-HTTP ``http_listen`` on ``tunnel_port + 1``) - bind the docker bridge gateway on Linux (``172.17.0.1`` or whatever - ``docker0`` resolves to — that's what ``host.docker.internal`` - resolves to inside containers there) and loopback on macOS / Windows - Docker Desktop. We do NOT bind ``0.0.0.0`` — a LAN peer with a - leaked sandbox token could otherwise spend the operator's API quota - against any allowlisted upstream. - - SSRF policy: ``upstream_deny_cidrs`` defaults to a conservative deny - list covering loopback, link-local (incl. AWS/GCP/Azure IMDS at - 169.254.169.254), and RFC1918. Pass an explicit ``[]`` to opt out of - the deny list entirely (only sensible in hermetic tests). - - Schema mirrors the official iron-proxy schema as of v0.39.0. Notable - points: - - * The ``dns`` section is required by the binary even when we only use the - CONNECT tunnel. We point it at loopback so it doesn't conflict with - anything else and disable the listener. - * The ``proxy.tunnel_listen`` is what sandboxes hit via ``HTTPS_PROXY``. - ``http_listen`` / ``https_listen`` are present (loopback only) so the - proxy boots; sandboxes never route directly to them. - * ``allowlist`` transform takes ``domains:`` and ``cidrs:``, not ``hosts:``. - * ``secrets`` transform takes ``secrets:`` (plural), each with a - ``source``, a ``replace.proxy_value`` (the sandbox-visible token), and - a list of ``rules`` saying which hosts the swap should fire on. - """ - - hosts: List[str] = list(allowed_hosts or _DEFAULT_ALLOWED_HOSTS) - for m in mappings: - for h in m.upstream_hosts: - if h not in hosts: - hosts.append(h) - - 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, - # Per-provider header set: bearer providers match only - # Authorization; header-auth providers (Anthropic native - # x-api-key, Azure api-key, Gemini x-goog-api-key) match - # their native header (+ Authorization where the provider - # also accepts bearer flows). v0.39 matches header names - # case-insensitively — see parseHeaderMatchers upstream. - "match_headers": match_headers, - # The token is also accepted as a query param — v0.39 scans - # every query parameter for the token value, which covers - # SDKs that pass ``?key=`` (Gemini) as well as - # bearer-in-query styles. Body matching is off — we - # don't want body inspection forced for every request. - "match_query": True, - "match_body": False, - # Fail closed (maxpetrusenko P1): when a request reaches an - # allowlisted upstream WITHOUT the proxy token present in a - # matched location, reject it instead of forwarding as-is. - # Without this, a real provider key that a sandbox process - # sent directly (not via the minted token) would still pass - # the proxy boundary to the allowed host. With require=true, - # iron-proxy returns ActionReject when no token swap fired - # (v0.39 secrets transform: replaceConfig.Require, enforced in - # TransformRequest — verified present in the pinned version). - "require": True, - }, - "rules": [{"host": h} for h in m.upstream_hosts], - }) - - # SSRF protection: default-deny cloud metadata + loopback + RFC1918. - # Callers can pass [] to opt out entirely (hermetic tests need this for - # talking to a loopback upstream). None means "use the default". - deny_cidrs: List[str] - if upstream_deny_cidrs is None: - deny_cidrs = list(_DEFAULT_UPSTREAM_DENY_CIDRS) - else: - deny_cidrs = list(upstream_deny_cidrs) - - # Listen addresses. iron-proxy v0.39 takes a single string per - # listener field — there is no plural ``http_listens`` form, despite - # earlier drafts of this module claiming v0.39 accepts both. An - # empirical strings(1) audit + a live "start the binary and observe - # the YAML unmarshal error" confirms the singular form is the only - # one the binary accepts. - # - # LISTENER ROLES (verified live against the v0.39 binary): - # * ``tunnel_listen`` is the CONNECT + MITM listener. HTTPS through - # ``HTTPS_PROXY`` issues CONNECT — this is the listener sandboxes - # must reach. A CONNECT sent to ``http_listen`` is NOT terminated: - # v0.39 forwards it upstream as a regular request and the upstream - # responds 400. - # * ``http_listen`` is the absolute-form plain-HTTP forward listener - # (``HTTP_PROXY`` for ``http://`` URLs). Transforms fire here too. - # Both get the sandbox-facing bind host: tunnel on ``tunnel_port``, - # plain HTTP on ``tunnel_port + 1``. - # - # The bind host comes from _default_http_listen: the docker bridge - # gateway on Linux (containers reach the proxy via - # host.docker.internal, which maps to the bridge gateway there — - # loopback would be unreachable from inside sandboxes) and loopback - # on macOS/Windows Docker Desktop (where host.docker.internal routes - # to the host via VPNkit). - listens = list(http_listen) if http_listen else _default_http_listen(tunnel_port) - primary_listen = listens[0] if listens else f"127.0.0.1:{tunnel_port}" - bind_host = primary_listen.rsplit(":", 1)[0] or "127.0.0.1" - plain_http_listen = f"{bind_host}:{tunnel_port + 1}" - - log_block: Dict = {"level": "info"} - # NOTE: ``log.audit_path`` is NOT a field in iron-proxy v0.39's - # ``config.Log`` struct — the binary rejects it with - # ``field audit_path not found in type config.Log``. Per-request - # audit records are written to the same log destination as - # everything else at this binary version; the operator-facing - # ``audit.log`` file we pre-create is still useful as a sentinel - # for monitoring (logrotate target, downstream tail watchers) but - # the daemon does not write to it directly. The kwarg is kept so - # we're forward-compatible with a future v0.40+ that adds the - # field; if you upgrade _IRON_PROXY_VERSION and the upstream gains - # ``log.audit_path``, re-enable the line below. - # if audit_log is not None: - # log_block["audit_path"] = str(audit_log) - _ = audit_log # consumed by ensure_audit_log() / docs only on v0.39 - - return { - # DNS section is required by the binary's config parser, but we run - # in tunnel-only mode so the DNS listener never binds an exposed port. - # Sandboxes reach the proxy via HTTPS_PROXY/CONNECT, not via DNS - # redirection. - "dns": { - "listen": "127.0.0.1:0", # ephemeral loopback — effectively disabled - "proxy_ip": "127.0.0.1", - }, - "proxy": { - # tunnel_listen is the CONNECT/MITM listener — what sandboxes - # hit via `HTTPS_PROXY=http://host:tunnel_port` for HTTPS - # upstreams (curl/requests/node issue CONNECT through it). - # http_listen handles absolute-form plain-HTTP forwards - # (`HTTP_PROXY` for http:// URLs) on tunnel_port+1. Both - # bind the docker bridge gateway on Linux / loopback on - # Docker Desktop — NEVER 0.0.0.0. LAN peers with a leaked - # sandbox token would otherwise be able to spend the - # operator's API quota against any allowlisted upstream. - "tunnel_listen": primary_listen, - "http_listen": plain_http_listen, - # The HTTPS-listener (direct TLS termination, no CONNECT) - # gets a loopback ephemeral port — we don't expose it. - "https_listen": "127.0.0.1:0", - "max_request_body_bytes": 16 * 1024 * 1024, - "max_response_body_bytes": 0, - "upstream_response_header_timeout": "120s", - # SSRF protection: deny outbound to cloud metadata + loopback by - # default. An empty list opts out entirely. - "upstream_deny_cidrs": deny_cidrs, - }, - # iron-proxy v0.39 starts a Prometheus-style metrics server by - # default on ``:9090`` — which is the SAME port as our default - # ``tunnel_port: 9090``, causing a guaranteed bind collision on - # startup. Pin the metrics listener to an ephemeral loopback - # port (``127.0.0.1:0``) so the metrics binding can't collide - # with the proxy listener regardless of what tunnel_port the - # operator chose. NOTE: ``:0`` means the kernel picks a fresh - # random port each start and nothing records it — metrics are - # effectively disabled/undiscoverable at this pin. If we want - # scrapable metrics later, allocate a fixed port and surface it - # in ``ProxyStatus`` / ``hermes egress status``. - "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), - "cert_cache_size": 1000, - "leaf_cert_expiry_hours": 168, - }, - "transforms": [ - { - "name": "allowlist", - "config": {"domains": hosts}, - }, - { - "name": "secrets", - "config": {"secrets": secrets_rules}, - }, - ], - "log": log_block, - } - - -def ensure_audit_log(audit_path: Path) -> None: - """Create the audit log file with private permissions (0o600). - - Called from the wizard right before ``start_proxy``. On the pinned - v0.39 the daemon never writes this file (no ``log.audit_path`` - config field), so the pre-create is purely forward-compat: when the - pin moves to a version that supports a dedicated audit stream, the - file already exists with tight permissions and the daemon inherits - them instead of creating it under the default umask. - - Raises :class:`RuntimeError` on any OSError (planted symlink, - immutable parent dir, full disk) so the caller can decide how to - surface it. The wizard treats this as a WARNING on v0.39 — the - file is non-load-bearing until the version bump — but the qualified - message keeps operators from wiring monitoring to a path that can't - exist. - """ - - try: - # Use os.open + O_CREAT to avoid races on the chmod. - open_flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND - if hasattr(os, "O_NOFOLLOW"): - open_flags |= os.O_NOFOLLOW - fd = os.open(str(audit_path), open_flags, 0o600) - try: - # Tighten perms even if the file already existed under a - # slacker umask. - os.fchmod(fd, 0o600) - finally: - os.close(fd) - except OSError as exc: - raise RuntimeError( - f"Refusing to start: could not pre-create audit log " - f"{audit_path} with restrictive permissions ({exc}). " - f"Move or chmod any existing file at that path and retry." - ) from exc - - -def write_proxy_config(config: Dict) -> Path: - """Serialize the config dict to ``/proxy/proxy.yaml``. - - Uses ``yaml.safe_dump`` so we never emit Python tags. - """ - - try: - import yaml # PyYAML is already a Hermes dep - except ImportError as exc: - raise RuntimeError( - "PyYAML is required to write the iron-proxy config but is not " - "installed." - ) from exc - - state = _proxy_state_dir() - out = state / "proxy.yaml" - tmp_path = state / ".proxy.yaml.tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False) - # Tighten perms on the temp file BEFORE the atomic replace so the - # final path is never briefly world-readable under a slack umask - # (the config embeds proxy token values). chmod-after-replace would - # leave a TOCTOU window; the 0o700 state dir mitigates but same-uid - # processes could still race. - os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) - os.replace(tmp_path, out) - return out - - -def write_mappings(mappings: List[TokenMapping]) -> Path: - """Persist the sandbox-visible proxy tokens to ``mappings.json``. - - The Docker backend reads this file to inject the right tokens as env - vars when starting a sandbox. The file is NOT read by iron-proxy - itself — the mapping is already baked into ``proxy.yaml``. - """ - - state = _proxy_state_dir() - out = state / "mappings.json" - payload = { - "version": 1, - "tokens": [ - { - "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 - ], - } - tmp_path = state / ".mappings.json.tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) - # chmod before the atomic replace — see write_proxy_config. The - # mappings file holds proxy token values, so close the TOCTOU window - # rather than chmod-ing after the file is already at its final path. - os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) - os.replace(tmp_path, out) - return out - - -def load_mappings() -> List[TokenMapping]: - """Read mappings.json, if it exists. Empty list on any error.""" - - state = _proxy_state_dir() - f = state / "mappings.json" - if not f.exists(): - return [] - try: - payload = json.loads(f.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - logger.warning("Failed to read iron-proxy mappings.json: %s", exc) - return [] - out: List[TokenMapping] = [] - for item in payload.get("tokens", []): - try: - out.append(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 - return out - - -def discover_provider_mappings( - *, - available_env_names: Optional[List[str]] = None, -) -> List[TokenMapping]: - """Mint a TokenMapping for every known provider whose env var is set. - - Pass ``available_env_names`` to override the lookup source (used by the - Bitwarden adapter so we mint mappings for keys that *will* be in the - proxy's environment even if they aren't in the host process env right - now). - """ - - if available_env_names is not None: - names = set(available_env_names) - else: - names = {k for k, v in os.environ.items() if v} - - mappings: List[TokenMapping] = [] - for env_name, hosts in _BEARER_PROVIDERS.items(): - if env_name not in names: - continue - mappings.append(TokenMapping( - proxy_token=mint_proxy_token(prefix=env_name.lower().replace("_api_key", "")), - 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 - - -def discover_uncovered_providers( - *, - available_env_names: Optional[List[str]] = None, -) -> List[str]: - """Return env-var names for providers we recognize but can't proxy. - - 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 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: - names = set(available_env_names) - else: - names = {k for k, v in os.environ.items() if v} - - return [n for n in _NON_BEARER_PROVIDERS if n in names] - - -def merge_mappings( - *, - existing: List[TokenMapping], - discovered: List[TokenMapping], - rotate: bool = False, -) -> List[TokenMapping]: - """Combine an existing mapping set with freshly discovered providers. - - By default this PRESERVES tokens for providers already in ``existing`` — - re-running ``hermes egress setup`` should not invalidate the tokens - baked into containers that are already running. Only newly added - providers get freshly minted tokens. - - When ``rotate=True``, every token in the result is freshly minted - regardless of overlap. The wizard exposes this via ``--rotate-tokens`` - for the rare case where the operator wants to roll all tokens - deliberately (e.g. after a suspected token leak). - - Providers that are in ``existing`` but no longer in ``discovered`` - (operator removed the env var since last setup) are dropped. - """ - - by_name = {m.real_env_name: m for m in existing} - out: List[TokenMapping] = [] - for d in discovered: - prior = by_name.get(d.real_env_name) - if prior is not None and not rotate: - # 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) - return out - - -# --------------------------------------------------------------------------- -# Subprocess lifecycle -# --------------------------------------------------------------------------- - - -def _pidfile() -> Path: - return _proxy_state_dir() / "iron-proxy.pid" - - -def _read_pid() -> Optional[int]: - # Use the read-only path: don't create the proxy dir just to read the - # pidfile. If neither pid file nor dir exists, the daemon is plainly - # not running. - pf = _proxy_state_dir_ro() / "iron-proxy.pid" - if not pf.exists(): - return None - try: - pid = int(pf.read_text(encoding="utf-8").strip()) - except (OSError, ValueError): - return None - return pid if pid > 0 else None - - -# Nonce env-var set in the iron-proxy subprocess at start_proxy time. Used -# by ``_pid_alive`` to confirm a candidate PID still refers to *our* managed -# binary even across PID recycling (a fresh process can't inherit our -# arbitrary env value). -_HERMES_IRON_PROXY_NONCE_ENV = "HERMES_IRON_PROXY_NONCE" -_proxy_nonce: Optional[str] = None - - -def _pid_proc_starttime(pid: int) -> Optional[str]: - """Return /proc//stat[21] (starttime) on Linux, else None. - - Comparing starttime is the standard cheap way to detect PID recycling - without relying on cmdline scanning. When None, callers fall back to - the cmdline + nonce check. - """ - try: - text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") - except OSError: - return None - # /proc//stat: pid (comm-with-parens) state ppid ... fields[21]=starttime - # The "comm" field can contain spaces and parens, so split from the - # right parenthesis instead of using shlex. - rparen = text.rfind(")") - if rparen < 0: - return None - fields = text[rparen + 1:].split() - # field index in the post-")" tail: original 3..n become fields[0..n-3] - # starttime is original field 22 (1-indexed) → tail index 22-3 = 19 - if len(fields) <= 19: - return None - return fields[19] - - -def _persisted_nonce_path() -> Path: - """Path to the on-disk sibling of the pidfile that stores the nonce. - - Written by ``_write_pidfile_safely`` after ``start_proxy`` plants - the nonce in the iron-proxy child env, read by ``_pid_alive`` in a - later CLI invocation (``stop`` / ``status``) so cross-process - PID-recycling defense holds. - """ - return _proxy_state_dir_ro() / "iron-proxy.nonce" - - -def _read_persisted_nonce() -> Optional[str]: - """Read the on-disk nonce written next to the pidfile. - - Returns None when the file is missing, unreadable, or empty — - callers fall back to argv0 basename matching in that case. - """ - p = _persisted_nonce_path() - try: - # O_NOFOLLOW: defence-in-depth against a planted symlink at the - # nonce path; same-uid required to plant one but worth defending - # since the nonce read here decides whether stop_proxy will - # SIGKILL a candidate PID. - flags = os.O_RDONLY - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - fd = os.open(str(p), flags) - except OSError: - return None - try: - # Ownership check — if the file isn't owned by us, ignore it. - # Same threat model as the pidfile uid check. - try: - st = os.fstat(fd) - if hasattr(os, "getuid") and st.st_uid != os.getuid(): - return None - except AttributeError: - pass - data = os.read(fd, 256).decode("utf-8", errors="ignore").strip() - return data or None - finally: - os.close(fd) - - -def _pid_alive(pid: int) -> bool: - """Return True iff ``pid`` is alive AND is an iron-proxy process. - - Defends against PID reuse via three signals (in priority order): - 1. ``/proc//environ`` contains our nonce (most reliable, Linux) - 2. ``/proc//cmdline`` basename matches the managed binary - 3. ``ps -p `` command line contains the binary path - - The legacy ``"iron-proxy" in cmdline`` match was loose enough to match - ``tail iron-proxy.log`` or an editor with that file open. We tighten - on argv[0] basename plus an in-process nonce instead. - """ - - if pid <= 0: - return False - try: - # Use psutil.pid_exists when available — it's a no-op on Windows - # whereas os.kill(pid, 0) on Windows is actually a hard kill - # (CTRL_C_EVENT to the target's console process group). See - # bpo-14484. windows-footgun: ok — we explicitly skip the - # os.kill probe on Windows below. - import psutil # type: ignore - if not psutil.pid_exists(pid): - return False - except ImportError: - if platform.system() == "Windows": - # On Windows without psutil we can't safely probe — assume - # the pidfile content is fresh and confirm via the cmdline - # path below. os.kill(pid, 0) is NOT safe here. - pass - else: - try: - os.kill(pid, 0) # windows-footgun: ok — POSIX-only branch - except (ProcessLookupError, PermissionError, OSError): - return False - - # Strong proof: nonce env var matches. /proc//environ is null- - # separated KEY=VALUE pairs; substring search is safe. - # - # The nonce can come from either: - # 1. the module-global ``_proxy_nonce`` set during this process's - # own ``start_proxy`` call (same-process case); - # 2. the on-disk ``iron-proxy.nonce`` file written by - # ``_write_pidfile_safely``, used when ``start`` and ``stop`` - # run in separate CLI invocations (cross-process case). - # Either source provides the same defeat-PID-recycling guarantee. - nonce_candidates: List[str] = [] - if _proxy_nonce: - nonce_candidates.append(_proxy_nonce) - on_disk = _read_persisted_nonce() - if on_disk and on_disk not in nonce_candidates: - nonce_candidates.append(on_disk) - if nonce_candidates: - try: - env_bytes = Path(f"/proc/{pid}/environ").read_bytes() - for nonce in nonce_candidates: - needle = f"{_HERMES_IRON_PROXY_NONCE_ENV}={nonce}".encode() - if needle in env_bytes: - return True - except OSError: - pass - - # Fallback: cmdline basename match. argv[0] is the first null- - # separated token in /proc//cmdline. - try: - cmdline_path = Path(f"/proc/{pid}/cmdline") - if cmdline_path.exists(): - tokens = cmdline_path.read_bytes().split(b"\x00") - if tokens: - argv0 = tokens[0].decode("utf-8", errors="ignore") - argv0_base = os.path.basename(argv0) - if argv0_base.startswith("iron-proxy"): - return True - return False - except OSError: - pass - - # macOS / non-Linux fallback: ``ps`` command basename. - try: - res = subprocess.run( # noqa: S603 - ["ps", "-p", str(pid), "-o", "comm="], - capture_output=True, text=True, timeout=2, - ) - if res.returncode == 0: - comm = (res.stdout or "").strip() - return os.path.basename(comm).startswith("iron-proxy") - except (OSError, subprocess.TimeoutExpired): - pass - - # Exotic platforms: be conservative — if the OS says alive we believe - # it. This restores the previous behaviour for non-Linux/non-macOS. - return True - - -def start_proxy( - *, - binary: Optional[Path] = None, - config_path: Optional[Path] = None, - extra_env: Optional[Dict[str, str]] = None, - install_if_missing: bool = True, - refresh_secrets_from_bitwarden: bool = False, - bitwarden_config: Optional[Dict] = None, -) -> ProxyStatus: - """Spawn iron-proxy as a managed background subprocess. - - Idempotent — if the proxy is already running with the expected PID, - just returns the live status. - - ``refresh_secrets_from_bitwarden=True`` re-fetches upstream secrets - via ``bws secret list`` at startup and injects them into the child - env. This delivers the rotation promise that distinguishes - ``credential_source: bitwarden`` from ``credential_source: env``. - Without this flag (or with ``bitwarden_config=None``) the proxy still - starts but uses whatever the host process env happens to contain. - """ - - global _proxy_nonce - - existing = _read_pid() - if existing and _pid_alive(existing): - return get_status() - - bin_path = binary or find_iron_proxy(install_if_missing=install_if_missing) - if bin_path is None: - raise RuntimeError( - "iron-proxy binary not available — run `hermes egress install`." - ) - - cfg = config_path or (_proxy_state_dir() / "proxy.yaml") - if not cfg.exists(): - raise RuntimeError( - f"iron-proxy config not found at {cfg}. " - "Run `hermes egress setup` first." - ) - - # Build a minimal subprocess env. os.environ.copy() would ship every - # secret in the operator's shell to the proxy — /proc//environ - # would then expose OPENAI_API_KEY, AWS keys, etc. to any same-uid - # local process. Defeats the threat model the proxy exists to - # mitigate. - env = _build_proxy_subprocess_env( - extra_env=extra_env, - refresh_from_bitwarden=refresh_secrets_from_bitwarden, - 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 - # Hermes process. - _proxy_nonce = hashlib.sha256(os.urandom(16)).hexdigest() - env[_HERMES_IRON_PROXY_NONCE_ENV] = _proxy_nonce - - log_path = _proxy_state_dir() / "iron-proxy.log" - # Keep ownership of the fd tight: open with explicit 0o600 so the - # log doesn't get world-readable under a slack umask, then close it - # immediately after Popen (the child has its own dup). Without the - # close-on-success path, every restart leaked one fd in the Hermes - # process. - # - # O_NOFOLLOW (defence-in-depth, same threat model as the pidfile - # path): a same-uid attacker who plants ``iron-proxy.log`` as a - # symlink to e.g. ``~/.ssh/authorized_keys`` would otherwise cause - # every restart to append daemon diagnostics to that file. - log_open_flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND - if hasattr(os, "O_NOFOLLOW"): - log_open_flags |= os.O_NOFOLLOW - try: - log_fd = os.open(str(log_path), log_open_flags, 0o600) - except OSError as exc: - # ELOOP from a planted symlink — refuse with a clear error. - raise RuntimeError( - f"Refusing to write iron-proxy log {log_path}: {exc}. " - "Remove that path manually and retry." - ) from exc - try: - os.fchmod(log_fd, 0o600) # tighten if file pre-existed - except OSError: - pass - # Verify ownership — same st_uid check the pidfile uses. - try: - st = os.fstat(log_fd) - if hasattr(os, "getuid") and st.st_uid != os.getuid(): - os.close(log_fd) - raise RuntimeError( - f"iron-proxy log {log_path} has unexpected owner " - f"uid={st.st_uid}; refusing to write." - ) - except AttributeError: - pass # Windows - - try: - # Use the fd directly via the dup mechanism; Popen will dup() it - # into the child so we can close ours unconditionally below. - # NOTE: on Windows ``start_new_session`` is invalid; we don't - # support Windows for the proxy (the binary itself doesn't ship) - # but the kwarg is POSIX-only and silently ignored on Win. - popen_kwargs: Dict = dict( - env=env, - stdin=subprocess.DEVNULL, - stdout=log_fd, - stderr=subprocess.STDOUT, - ) - if platform.system() != "Windows": - popen_kwargs["start_new_session"] = True - proc = subprocess.Popen( # noqa: S603 — binary path is trusted - [str(bin_path), "-config", str(cfg)], - **popen_kwargs, - ) - except OSError as exc: - os.close(log_fd) - raise RuntimeError(f"failed to spawn iron-proxy: {exc}") from exc - finally: - # Close our copy of the fd whether Popen raised or succeeded. - # The child has its own dup via Popen, so it's still writing. - try: - os.close(log_fd) - except OSError: - pass - - # Write the pidfile IMMEDIATELY after Popen, BEFORE the listening - # verification. If the parent dies during the poll loop (SIGINT, - # OOM, kernel pause), the pidfile is still on disk so the next - # ``hermes egress stop`` can clean up the orphan. Failure paths - # below unlink the pidfile when they kill the child. - pidfile = _pidfile() - try: - _write_pidfile_safely(pidfile, proc.pid) - except RuntimeError: - # Kill the orphan so we don't leave a daemon nobody can stop. - _kill_and_wait(proc, grace_seconds=2) - raise - - # Poll-with-timeout instead of an unconditional 5s sleep. The Go - # binary normally comes up in <200ms; falling through within 100ms - # of liveness keeps Docker container creation snappy. - # - # We scope a Ctrl-C handler around the poll loop so an operator who - # hits Ctrl-C while waiting for ``hermes egress start`` doesn't leak - # an orphan with the port bound. - # - # Probe the CONFIGURED bind host, not loopback unconditionally — on - # Linux the daemon binds the docker bridge gateway, where a loopback - # connect never succeeds and we'd kill a healthy daemon as "never - # came up". - listen_hp = _read_http_listen_from_config() - if listen_hp is not None: - probe_host, tunnel_port = listen_hp - else: - probe_host, tunnel_port = "127.0.0.1", _DEFAULT_TUNNEL_PORT - listening = False - - def _interrupt_handler(_signum, _frame): # pragma: no cover - signal path - # Kill the child and unlink the pidfile, then re-raise so the - # caller sees the interrupt. - _kill_and_wait(proc, grace_seconds=2) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise KeyboardInterrupt() - - prev_sigint = None - prev_sigterm = None - install_handlers = ( - platform.system() != "Windows" - and threading.current_thread() is threading.main_thread() - ) - if install_handlers: - prev_sigint = signal.signal(signal.SIGINT, _interrupt_handler) - prev_sigterm = signal.signal(signal.SIGTERM, _interrupt_handler) - try: - deadline = time.time() + _STARTUP_GRACE_SECONDS - # Do-while shape: check listening at least once even when the - # grace window is 0 (test harness / synchronous fast-path). - while True: - if proc.poll() is not None: - tail = _tail_log(log_path, lines=20) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise RuntimeError( - f"iron-proxy exited immediately (code {proc.returncode}). " - f"Last log lines:\n{tail}" - ) - if _port_listening(probe_host, tunnel_port): - listening = True - break - if time.time() >= deadline: - break - time.sleep(0.1) - finally: - if install_handlers: - signal.signal(signal.SIGINT, prev_sigint) - signal.signal(signal.SIGTERM, prev_sigterm) - - # Final exit check — process may have died right at deadline. - if proc.poll() is not None: - tail = _tail_log(log_path, lines=20) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise RuntimeError( - f"iron-proxy exited immediately (code {proc.returncode}). " - f"Last log lines:\n{tail}" - ) - - # The previous version of this code treated "process still alive at - # deadline" as success. That left iron-proxy running but - # non-listening on the port, with a pidfile pointing at it — - # subsequent restarts would fail with "address in use" because the - # orphan still held the port. Require port-listening for success. - if not listening: - tail = _tail_log(log_path, lines=20) - _kill_and_wait(proc, grace_seconds=2) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise RuntimeError( - f"iron-proxy did not bind {probe_host}:{tunnel_port} within " - f"{_STARTUP_GRACE_SECONDS}s. Process was killed. " - f"Last log lines:\n{tail}" - ) - - logger.info("Started iron-proxy pid=%s config=%s", proc.pid, cfg) - return get_status() - - -def _write_pidfile_safely(pidfile: Path, pid: int) -> None: - """Write ``pid`` to ``pidfile`` with O_EXCL + O_NOFOLLOW + ownership check. - - O_EXCL means "another start is in progress" if the file already - exists with a live owner — we cleanly fail rather than racing. When - the existing pidfile points at a dead pid (stale crash), we - explicitly unlink it before retrying once. - - Side effect: also persists the in-process nonce to disk so - cross-CLI-invocation ``_pid_alive`` checks (start in one process, - stop in another) can still defeat PID recycling. - """ - open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if hasattr(os, "O_NOFOLLOW"): - open_flags |= os.O_NOFOLLOW - try: - fd = os.open(str(pidfile), open_flags, 0o600) - except FileExistsError: - # Pidfile already exists. If it points at a live iron-proxy, - # caller's _read_pid + _pid_alive at the top of start_proxy - # should already have returned. Reaching here means EITHER - # the previous _pid_alive check raced (rare; another start in - # flight), OR a stale pidfile survived a crash. Discriminate - # and retry once with O_TRUNC if stale. - existing_pid = _read_pid() - if existing_pid and _pid_alive(existing_pid): - raise RuntimeError( - f"Another iron-proxy start appears to be in progress " - f"(pidfile {pidfile} -> pid {existing_pid}). " - f"Run `hermes egress stop` if that proxy is stuck." - ) - # Stale — unlink and retry. - try: - pidfile.unlink() - except FileNotFoundError: - pass - fd = os.open(str(pidfile), open_flags, 0o600) - except OSError as exc: - # ELOOP from a planted symlink at the pidfile path. - raise RuntimeError( - f"Refusing to write pidfile {pidfile}: {exc}. " - "Remove that path manually and retry." - ) from exc - - try: - # Ownership check — same st_uid pattern the log file uses. - try: - st = os.fstat(fd) - if hasattr(os, "getuid") and st.st_uid != os.getuid(): - raise RuntimeError( - f"pidfile {pidfile} has unexpected owner uid={st.st_uid}" - ) - except AttributeError: - pass # Windows - os.write(fd, str(pid).encode("utf-8")) - finally: - os.close(fd) - - # Persist the nonce next to the pidfile (sibling, 0o600). - # ``stop_proxy`` in a separate CLI invocation can read this and use - # it to confirm the pid still refers to our binary even though the - # module-global ``_proxy_nonce`` is fresh in the new process. - if _proxy_nonce: - noncefile = pidfile.with_suffix(".nonce") - nfd = -1 - try: - nopen = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - if hasattr(os, "O_NOFOLLOW"): - nopen |= os.O_NOFOLLOW - nfd = os.open(str(noncefile), nopen, 0o600) - os.write(nfd, _proxy_nonce.encode("utf-8")) - except OSError: - # Best-effort. Without the nonce file we fall back to - # argv0-basename matching, which is what we did before. - pass - finally: - if nfd >= 0: - try: - os.close(nfd) - except OSError: - pass - - -def _kill_and_wait(proc: "subprocess.Popen", *, grace_seconds: int = 2) -> None: - """Best-effort SIGTERM → wait → SIGKILL for a child we own.""" - try: - proc.terminate() - except OSError: - return - try: - proc.wait(timeout=grace_seconds) - except subprocess.TimeoutExpired: - try: - proc.kill() - except OSError: - pass - try: - proc.wait(timeout=grace_seconds) - except subprocess.TimeoutExpired: - pass - - -def _build_proxy_subprocess_env( - *, - extra_env: Optional[Dict[str, str]] = None, - refresh_from_bitwarden: bool = False, - bitwarden_config: Optional[Dict] = None, -) -> Dict[str, str]: - """Construct the minimal env for the iron-proxy subprocess. - - Allowlists infrastructure vars (PATH, HOME, locale) plus the env vars - named in ``load_mappings()`` (the real upstream secrets the proxy - needs to do the swap). Everything else is stripped — see - ``_PROXY_SUBPROCESS_ENV_STRIP`` for proxy chain protection. - - When ``refresh_from_bitwarden=True`` AND ``bitwarden_config`` is - populated, fetches upstream secrets via the BSM SDK at startup and - merges them in. This is what delivers the rotation guarantee - promised by ``credential_source: bitwarden`` — without it, rotating - a key in the Bitwarden web app doesn't reach the proxy. - """ - - env: Dict[str, str] = {} - parent = os.environ - for name in _PROXY_SUBPROCESS_ENV_ALLOWLIST: - if name in parent: - env[name] = parent[name] - - # 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. 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 - # every install. - if refresh_from_bitwarden and bitwarden_config: - try: - from agent.secret_sources import bitwarden as bw - access_token_name = bitwarden_config.get( - "access_token_env", "BWS_ACCESS_TOKEN" - ) - access_token = parent.get(access_token_name, "").strip() - project_id = bitwarden_config.get("project_id", "") - if access_token and project_id: - secrets, warnings = bw.fetch_bitwarden_secrets( - access_token=access_token, - project_id=project_id, - cache_ttl_seconds=0, - use_cache=False, - ) - # Only inject env names we have a mapping for — extra - # secrets in the BW project shouldn't leak into the proxy - # process unless they're going to be used by the swap. - missing = sorted(needed - set(secrets)) - for n in needed: - if n in secrets: - env[n] = secrets[n] - if missing: - # stephenschoettler #1: don't silently keep stale - # host-env values when BWS mode was explicitly - # selected. An operator on credential_source=bitwarden - # picked it specifically to get rotation; falling back - # to parent env reintroduces the bug class the mode - # is supposed to defeat. ``allow_env_fallback`` is the - # documented, deliberate opt-out — honor it here exactly - # as the empty-token branch below does (the error - # message tells operators to set it, so it must work). - if not (bitwarden_config or {}).get("allow_env_fallback"): - raise RuntimeError( - f"Bitwarden refresh did not return secrets for " - f"{missing}. Either add the secrets to your BWS " - f"project, switch to credential_source: env via " - f"`hermes egress setup --no-bitwarden`, or set " - f"`proxy.allow_env_fallback: true` in config.yaml " - f"to opt into the legacy host-env fallback." - ) - logger.warning( - "Bitwarden refresh did not return secrets for %s — " - "falling back to host env for those names " - "(allow_env_fallback=true).", - missing, - ) - # bws warnings are non-secret status messages (e.g. "no - # project found", "rate limited"), but the taint analyzer - # can't tell that — log the count and let the operator - # rerun under verbose if they need detail. - if warnings: - logger.warning( - "Bitwarden refresh produced %d warning(s); " - "run `hermes secrets bitwarden status` for detail.", - len(warnings), - ) - else: - # NOTE: deliberately do not interpolate access_token_name - # in the log message — CodeQL's taint analyzer treats - # bitwarden_config values as secret-tainted (it can't - # distinguish the env-var NAME from the env-var VALUE). - # The name is non-secret but logging it just trips the - # check for no real benefit. - if not (bitwarden_config or {}).get("allow_env_fallback"): - raise RuntimeError( - "credential_source=bitwarden but the access-token " - "env or project_id is empty. Either set both, " - "switch to credential_source: env, or set " - "`proxy.allow_env_fallback: true` to opt into " - "the legacy fallback behaviour." - ) - logger.warning( - "credential_source=bitwarden but access-token env or " - "project_id is empty — proxy will fall back to parent env " - "(allow_env_fallback=true).", - ) - except (ImportError,) as exc: - # The BWS module or one of its runtime deps isn't importable. - # Mirror the sibling branches: if allow_env_fallback isn't - # explicitly enabled, fail closed — credential_source=bitwarden - # with a unavailable module should not silently degrade to host - # env. A wizard-time check can't catch a dependency that goes - # missing between setup and a later restart. - if not (bitwarden_config or {}).get("allow_env_fallback"): - raise RuntimeError( - "Bitwarden refresh module unavailable at proxy start " - "(credential_source=bitwarden with " - "proxy.allow_env_fallback: false). Either fix the " - "import, switch to credential_source: env, or set " - "`proxy.allow_env_fallback: true` to opt into the " - "legacy fallback behaviour." - ) from exc - logger.warning( - "Bitwarden refresh module unavailable at proxy start, " - "falling back to parent env (allow_env_fallback=true): %s", - exc, - ) - - # Caller-supplied overrides win. This is intentionally last so the - # wizard can inject ad-hoc test secrets without recomputing the BW - # path. - if extra_env: - env.update(extra_env) - - # Strip proxy-recursion-risk vars regardless of how they got in. - for name in _PROXY_SUBPROCESS_ENV_STRIP: - env.pop(name, None) - - env.setdefault("NO_COLOR", "1") - return env - - -def stop_proxy() -> bool: - """Stop the managed iron-proxy. Returns True if it was running.""" - - global _proxy_nonce - - def _cleanup_state_files() -> None: - """Best-effort cleanup of pidfile + persisted nonce.""" - _pidfile().unlink(missing_ok=True) - try: - _persisted_nonce_path().unlink() - except FileNotFoundError: - pass - except OSError: - pass - - pid = _read_pid() - if not pid or not _pid_alive(pid): - _cleanup_state_files() - _proxy_nonce = None - return False - - # Capture starttime BEFORE signalling so we can compare after the - # grace window — if the pid got recycled mid-wait, the starttime - # changes and we abort the SIGKILL. - starttime_before = _pid_proc_starttime(pid) - - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - _cleanup_state_files() - _proxy_nonce = None - return False - - # Wait up to 5s for graceful exit, then SIGKILL. - deadline = time.time() + 5.0 - while time.time() < deadline: - if not _pid_alive(pid): - break - time.sleep(0.1) - else: - # Verify the pid hasn't been recycled before delivering SIGKILL. - # Two checks: - # 1. /proc//stat starttime is unchanged (Linux) - # 2. _pid_alive() still says it's an iron-proxy process - starttime_after = _pid_proc_starttime(pid) - recycled = ( - starttime_before is not None - and starttime_after is not None - and starttime_before != starttime_after - ) or not _pid_alive(pid) - if recycled: - logger.warning( - "iron-proxy pid=%s appears recycled before SIGKILL; " - "not killing.", pid, - ) - else: - try: - os.kill(pid, _KILL_SIGNAL) - except ProcessLookupError: - pass - - _cleanup_state_files() - _proxy_nonce = None - logger.info("Stopped iron-proxy pid=%s", pid) - return True - - -def get_status() -> ProxyStatus: - """Snapshot the current proxy state — does NOT start anything. - - Crucially, this is called per Docker-container-create when egress - enforcement is on. It must not have side-effects (no mkdir, no - binary version subprocess that takes 30s on a hung binary). The - state dir is read-only here. - """ - - status = ProxyStatus() - listen_hp = _read_http_listen_from_config() - if listen_hp is not None: - probe_host, status.tunnel_port = listen_hp - else: - probe_host = "127.0.0.1" - status.tunnel_port = _DEFAULT_TUNNEL_PORT - - binary = find_iron_proxy(install_if_missing=False) - if binary: - status.binary_path = binary - # Cached — see iron_proxy_version(). First call still costs one - # subprocess; subsequent calls in the same process are dict - # lookups. - status.binary_version = iron_proxy_version(binary) - - state = _proxy_state_dir_ro() - cfg = state / "proxy.yaml" - ca = state / "ca.crt" - if cfg.exists(): - status.config_path = cfg - if ca.exists(): - status.ca_cert_path = ca - - pid = _read_pid() - if pid and _pid_alive(pid): - status.pid = pid - # Probe the configured bind host — on Linux that's the docker - # bridge gateway, where a loopback connect would report a healthy - # daemon as "not listening". - status.listening = _port_listening(probe_host, status.tunnel_port) - - return status - - -def _read_tunnel_port_from_config() -> Optional[int]: - listen = _read_http_listen_from_config() - if listen is None: - return None - return listen[1] - - -def _read_http_listen_from_config() -> Optional[Tuple[str, int]]: - """Return ``(host, port)`` of the configured sandbox-facing listener. - - Reads ``proxy.tunnel_listen`` — the CONNECT/MITM listener sandboxes - hit via ``HTTPS_PROXY`` — falling back to ``proxy.http_listen`` for - configs written before the tunnel/http listener-role split. - - The bind host matters for liveness probes: on Linux the daemon binds - the docker bridge gateway (e.g. ``172.17.0.1``), where a loopback - connect would report "not listening" for a perfectly healthy daemon. - """ - - cfg = _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 - proxy_block = (data or {}).get("proxy") or {} - # The CLI/Docker side calls this "the tunnel port" because that's how - # sandboxes use it (HTTPS_PROXY) — on the iron-proxy side it's the - # tunnel_listen (CONNECT + MITM). http_listen is the plain-HTTP - # forward listener on tunnel_port+1. - listen = proxy_block.get("tunnel_listen") or proxy_block.get("http_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 _port_listening(host: str, port: int) -> bool: - """Cheap TCP connect probe — True iff something accepts on host:port.""" - - import socket - - try: - with socket.create_connection((host, port), timeout=0.5): - return True - except OSError: - return False - - -def _tail_log(path: Path, *, lines: int = 20) -> str: - if not path.exists(): - return "(no log file)" - try: - data = path.read_bytes()[-8192:] - return "\n".join(data.decode("utf-8", errors="replace").splitlines()[-lines:]) - except OSError as exc: - return f"(could not read log: {exc})" - - -# --------------------------------------------------------------------------- -# Test hook -# --------------------------------------------------------------------------- - - -def _reset_for_tests() -> None: - """Clear module-level caches so tests get a fresh start. - - This module owns two mutable globals that need reset between tests: - - ``_VERSION_CACHE`` — subprocess output cache keyed by binary path. - - ``_proxy_nonce`` — the strong-proof token written by ``start_proxy`` - and read by ``_pid_alive`` to defeat PID recycling. - - Today the repo's tests run each file in its own subprocess (per - AGENTS.md) so leakage is bounded, but any in-process caller - (notebooks, ad-hoc scripts, ``pytest -p no:xdist``) would otherwise - see whichever values were probed first regardless of subsequent - ``install_iron_proxy(force=True)`` or ``start_proxy`` calls. - """ - - global _proxy_nonce - _VERSION_CACHE.clear() - _proxy_nonce = None - - -# Make a small set of symbols available without underscored access. -__all__ = [ - "ProxyStatus", - "TokenMapping", - "build_proxy_config", - "discover_provider_mappings", - "discover_uncovered_providers", - "ensure_audit_log", - "ensure_ca_cert", - "ensure_management_token", - "find_iron_proxy", - "get_status", - "install_iron_proxy", - "iron_proxy_version", - "load_mappings", - "merge_mappings", - "mint_proxy_token", - "reload_proxy", - "start_proxy", - "stop_proxy", - "write_mappings", - "write_proxy_config", -] diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 3179062f211..be89ebb4e12 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -240,7 +240,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ }, { icon: KeyRound, - keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'], + keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'], labelKey: 'providerApiKeys', tab: 'providers&pview=keys' }, @@ -253,7 +253,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ }, { icon: Settings2, - keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'], + keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'], labelKey: 'keysSettings', tab: 'keys&kview=settings' }, diff --git a/cli.py b/cli.py index d8d12512477..d2dbbbb0195 100644 --- a/cli.py +++ b/cli.py @@ -8584,10 +8584,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._show_gateway_status() elif canonical == "status": self._show_session_status() - elif canonical == "egress": - from hermes_cli.proxy_cli import format_status_text - - self._console_print(format_status_text(), highlight=False, markup=False) elif canonical == "statusbar": self._status_bar_visible = not self._status_bar_visible state = "visible" if self._status_bar_visible else "hidden" diff --git a/gateway/run.py b/gateway/run.py index a7efef73c36..b08650d08ac 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8952,11 +8952,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _cmd_def_inner and _cmd_def_inner.name == "restart": return await self._handle_restart_command(event) - if _cmd_def_inner and _cmd_def_inner.name == "egress": - from hermes_cli.proxy_cli import format_status_text - - return format_status_text() - # /stop must hard-kill the session when an agent is running. # A soft interrupt (agent.interrupt()) doesn't help when the agent # is truly hung — the executor thread is blocked and never checks @@ -9435,11 +9430,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "status": return await self._handle_status_command(event) - if canonical == "egress": - from hermes_cli.proxy_cli import format_status_text - - return format_status_text() - if canonical == "agents": return await self._handle_agents_command(event) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 0287d0d269b..7a8775d2604 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -118,8 +118,6 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session", args_hint="[text | remove N | clear]"), CommandDef("status", "Show session, model, token, and context info", "Session"), - CommandDef("egress", "Show Docker egress proxy status", "Session", - args_hint="[status]", subcommands=("status",)), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", @@ -559,7 +557,6 @@ _TELEGRAM_MENU_PRIORITY = ( "new", "stop", "status", - "egress", "resume", "sessions", "model", @@ -1166,8 +1163,7 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # - moa: high-cost slash mode, available through /hermes moa to avoid # displacing existing native Slack slash commands at the 50-command cap. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -# - egress: Docker-only proxy status; reachable as /hermes egress on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug", "egress"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 34a916da300..531d419ab80 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3090,66 +3090,6 @@ DEFAULT_CONFIG = { "cua_telemetry": False, }, - # ========================================================================= - # Egress credential-injection proxy (iron-proxy) - # ========================================================================= - # When enabled, outbound traffic from remote terminal sandboxes (Docker - # today; Modal/SSH in follow-ups) is routed through a managed iron-proxy - # subprocess. The sandbox sees opaque proxy tokens; iron-proxy swaps in - # real API credentials at the egress boundary. Compromising the sandbox - # leaks tokens that only work behind the configured trusted proxy boundary - # (CA private key + proxy endpoint integrity are part of that boundary). - # - # Configure with `hermes egress setup`. Disabled by default — the rest of - # Hermes works exactly as before with `enabled: false`. - "proxy": { - # Master switch. When false, iron-proxy is never started, no docker - # mounts are added, no binaries are auto-installed — feature is a - # complete no-op. - "enabled": False, - # Tunnel listener port. Sandboxes get `HTTPS_PROXY=http://:`. - # 9090 is the default; collide-aware setup wizard can reassign. - "tunnel_port": 9090, - # Auto-download the pinned iron-proxy binary into ~/.hermes/bin/ on - # first use. When false, you must place `iron-proxy` on PATH yourself. - "auto_install": True, - # Where iron-proxy looks up the real upstream secrets at egress time. - # "env" — process env (default; what bitwarden integration - # already populates if you use it) - # "bitwarden" — refetch via `bws secret list` on each proxy restart; - # rotation in the Bitwarden web app propagates without - # touching .env (requires `secrets.bitwarden.enabled`). - "credential_source": "env", - # When true, the Docker backend refuses to start a sandbox if the - # 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, - # 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 - # True to opt back in to the legacy "silently fall back to host - # env" behaviour — useful for migrations where the operator wants - # to switch credential_source to bitwarden but hasn't fully wired - # BWS yet. Defaults to false (strict). - "allow_env_fallback": False, - # SSRF deny list applied to outbound traffic. Omit / leave empty - # to use the safe default: loopback, link-local (incl. cloud - # metadata IPs at 169.254.169.254), and RFC1918. Set to an - # explicit ``[]`` to opt out entirely (only sensible in hermetic - # tests that need to reach a loopback upstream). - "upstream_deny_cidrs": None, - # Extra allowed upstream hosts beyond the bundled defaults (which - # cover OpenRouter, OpenAI, Anthropic, Google, xAI, Mistral, Groq, - # Together, DeepSeek, Nous). Wildcards (`*.foo.com`) are supported. - "extra_allowed_hosts": [], - }, - # Hermes Desktop (Electron app) launch options. These only affect # `hermes desktop`; they do not touch the CLI/gateway. "desktop": { diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f48b94220a9..4e483d318c3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12188,7 +12188,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion", "computer-use", "config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor", - "dump", "egress", "fallback", "gateway", "hooks", "import", "insights", + "dump", "fallback", "gateway", "hooks", "import", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa", "journey", "memory-graph", "learning", "model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", @@ -12803,37 +12803,6 @@ def main(): secrets_parser.set_defaults(func=_dispatch_secrets) - # ========================================================================= - # egress command — iron-proxy outbound credential-injection firewall - # ========================================================================= - # NOTE: this is the OUTBOUND egress firewall (ironsh/iron-proxy). - # `hermes proxy` (defined elsewhere in this file) is a separate INBOUND - # OAuth-aggregator reverse proxy. Different direction, different purpose. - egress_parser = subparsers.add_parser( - "egress", - help="Manage the iron-proxy egress credential-injection firewall", - description=( - "Manage iron-proxy, the optional TLS-intercepting egress firewall " - "that swaps proxy tokens for real API credentials before outbound " - "requests leave a sandbox. Disabled by default. See: " - "https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy" - ), - ) - - from hermes_cli import proxy_cli as _proxy_cli - _proxy_cli.register_cli(egress_parser) - - def _dispatch_egress(args): # noqa: ANN001 - # The egress subparser uses dest='egress_command' to stay disjoint - # from the inbound OAuth ``hermes proxy`` subparser (dest='proxy_command'). - sub = getattr(args, "egress_command", None) - if sub is not None and hasattr(args, "func") and args.func is not _dispatch_egress: - return args.func(args) - egress_parser.print_help() - return 0 - - egress_parser.set_defaults(func=_dispatch_egress) - # ========================================================================= # migrate command # ========================================================================= @@ -13944,15 +13913,9 @@ def main(): cmd_chat(args) return - # 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 when credential_source=bitwarden - # is misconfigured) actually exit non-zero. Handlers that return - # None are treated as success (exit 0). + # Execute the command if hasattr(args, "func"): - rc = args.func(args) - if isinstance(rc, int) and rc != 0: - sys.exit(rc) + args.func(args) else: parser.print_help() diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py deleted file mode 100644 index 2b90ec90a6c..00000000000 --- a/hermes_cli/proxy_cli.py +++ /dev/null @@ -1,904 +0,0 @@ -"""CLI handlers for ``hermes egress ...``. - -Subcommands: - install — download the pinned iron-proxy binary - setup — interactive wizard: install binary, generate CA, mint tokens, write config - start — launch the proxy as a managed subprocess - stop — terminate the managed proxy - status — show binary version + config presence + listen state + mappings - disable — flip ``proxy.enabled`` to False (does not stop a running proxy) - config — print the generated proxy.yaml path (for debugging / external review) - -The top-level command is ``hermes egress``. Note that the inbound OAuth -reverse-proxy command (``hermes proxy``) lives elsewhere in -``hermes_cli/main.py`` — different direction, different purpose. -""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path -from typing import List - -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. - - Called from ``hermes_cli.main`` as part of building the top-level - ``hermes egress`` 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") - - install = sub.add_parser( - "install", - help=f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})", - ) - install.add_argument( - "--force", action="store_true", - help="Re-download even if a managed copy already exists", - ) - install.set_defaults(func=cmd_install) - - setup = sub.add_parser( - "setup", - help="Interactive wizard: install + CA + mint tokens + write config", - ) - setup.add_argument( - "--tunnel-port", type=int, default=None, - help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})", - ) - setup.add_argument( - "--from-bitwarden", 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.", - ) - setup.add_argument( - "--no-bitwarden", action="store_true", - help="Explicitly switch credential_source back to env on re-setup " - "(only meaningful when the previous setup used --from-bitwarden).", - ) - setup.add_argument( - "--rotate-tokens", 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).", - ) - setup.add_argument( - "--restart", 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).", - ) - setup.add_argument( - "--no-restart", 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.", - ) - setup.set_defaults(func=cmd_setup) - - start = sub.add_parser("start", help="Start the managed iron-proxy") - start.set_defaults(func=cmd_start) - - stop = sub.add_parser("stop", help="Stop the managed iron-proxy") - stop.set_defaults(func=cmd_stop) - - restart = sub.add_parser( - "restart", - help="Restart the managed iron-proxy (stop if running, then start)", - ) - 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", - help="Print the proxy tokens (default: redacted prefix only). " - "Beware: tokens may persist in your shell history.", - ) - status.set_defaults(func=cmd_status) - - disable = sub.add_parser("disable", help="Turn off the proxy integration") - disable.set_defaults(func=cmd_disable) - - cfg = sub.add_parser("config", help="Print the generated proxy.yaml path") - cfg.set_defaults(func=cmd_config) - - -# --------------------------------------------------------------------------- -# 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: - 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 1 - 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 1 - secrets, _ = bw.fetch_bitwarden_secrets( - access_token=access_token, - project_id=bw_cfg.get("project_id", ""), - cache_ttl_seconds=0, - use_cache=False, - ) - available_env_names = list(secrets.keys()) - if not available_env_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 1 - console.print( - f" Pulled {len(available_env_names)} env names from Bitwarden." - ) - 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 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: - import sys as _sys - from datetime import datetime as _dt - 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." - ) - try: - ans = input("Type 'rotate' to confirm: ").strip().lower() - except EOFError: - ans = "" - if ans != "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: - import shutil as _shutil - state_dir = ip._proxy_state_dir() - mappings_src = state_dir / "mappings.json" - if mappings_src.exists(): - ts = _dt.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) - import sys as _sys - restart_pref = getattr(args, "restart", None) - if restart_pref is True: - do_restart = True - elif restart_pref is False: - do_restart = False - elif was_running: - if _sys.stdin.isatty(): - try: - ans = input( - " Restart the running proxy now with the new config? [Y/n] " - ).strip().lower() - except EOFError: - ans = "" - do_restart = ans in ("", "y", "yes") - else: - do_restart = 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) without making the operator remember the - stop/start dance. Delegates to ``cmd_start`` so all the credential-source - guards run exactly as they do 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 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() - proxy_cfg = cfg.get("proxy") or {} - status = ip.get_status() - - def yn(value: bool) -> str: - return "yes" if value else "no" - - lines = [ - "Egress proxy status", - "", - f"Enabled: {yn(bool(proxy_cfg.get('enabled')))}", - f"Binary: {status.binary_path or '(missing)'}", - f"Binary version: {status.binary_version or '(unknown)'}", - f"Config: {status.config_path or '(not generated)'}", - f"CA cert: {status.ca_cert_path or '(not generated)'}", - f"Tunnel port: {status.tunnel_port}", - f"Process: pid {status.pid}" if status.pid else "Process: (stopped)", - f"Listening: {yn(status.listening)}", - f"Credential src: {proxy_cfg.get('credential_source', 'env')}", - f"Docker enforce: {yn(bool(proxy_cfg.get('enforce_on_docker', True)))}", - "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("") - table.add_row("Enabled", _yn(bool(proxy_cfg.get("enabled")))) - table.add_row("Binary", str(status.binary_path or "[dim](missing)[/dim]")) - table.add_row("Binary version", status.binary_version or "[dim](unknown)[/dim]") - table.add_row("Config", str(status.config_path or "[dim](not generated)[/dim]")) - table.add_row("CA cert", str(status.ca_cert_path or "[dim](not generated)[/dim]")) - table.add_row("Tunnel port", str(status.tunnel_port)) - table.add_row("Process", f"pid {status.pid}" if status.pid else "[dim](stopped)[/dim]") - table.add_row("Listening", _yn(status.listening)) - table.add_row("Credential src", str(proxy_cfg.get("credential_source", "env"))) - table.add_row("Docker enforce", _yn(bool(proxy_cfg.get("enforce_on_docker", True)))) - 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 _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). - Without this, ``setup`` reports "no provider keys found" even though the - keys plainly exist — a confusing first-run papercut. - - 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 _redact_token(token: str) -> str: - if len(token) < 16: - return token - return f"{token[:12]}…{token[-4:]}" diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index fe28144cccd..9c3c7c1dcd3 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1234,28 +1234,6 @@ def setup_terminal_backend(config: dict): config["terminal"].setdefault( "docker_image", "nikolaik/python-nodejs:python3.11-nodejs20" ) - print() - print_info("Docker sandboxes can be protected with the egress credential firewall.") - print_info( - "It routes sandbox traffic through iron-proxy so containers receive " - "proxy tokens instead of real API keys." - ) - print_info( - " Docker only for now; Modal, SSH, Daytona, and Singularity are not wired yet." - ) - if prompt_yes_no(" Enable egress firewall for Docker sandboxes?", False): - proxy_cfg = config.setdefault("proxy", {}) - proxy_cfg["enabled"] = True - proxy_cfg.setdefault("enforce_on_docker", True) - print_success("Egress firewall enabled in config") - print_info( - "Run `hermes egress setup` then `hermes egress start` to mint " - "tokens and launch the proxy." - ) - else: - print_info( - "Skipping egress firewall. You can enable it later with `hermes egress setup`." - ) elif selected_backend == "singularity": print_success("Terminal backend: Singularity/Apptainer") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ca417fe422a..26f35fce384 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -626,25 +626,6 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Modal sandbox mode", "options": ["sandbox", "function"], }, - "proxy.enabled": { - "type": "boolean", - "description": ( - "Docker-only egress credential firewall. Requires `hermes egress setup` " - "and `hermes egress start`; Modal/SSH/Daytona are not wired yet." - ), - "category": "security", - }, - "proxy.credential_source": { - "type": "select", - "description": "Where iron-proxy loads real upstream secrets at start time", - "options": ["env", "bitwarden"], - "category": "security", - }, - "proxy.enforce_on_docker": { - "type": "boolean", - "description": "Refuse Docker sandboxes when egress is enabled but not configured/running", - "category": "security", - }, "tts.provider": { "type": "select", "description": "Text-to-speech provider", @@ -4163,14 +4144,6 @@ async def get_schema(): return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER} -@app.get("/api/egress/status") -async def get_egress_status(): - """Dashboard/Desktop-readable egress proxy status and remediation text.""" - from hermes_cli.proxy_cli import format_status_text - - return {"text": format_status_text()} - - _EMPTY_MODEL_INFO: dict = { "model": "", "provider": "", diff --git a/scripts/release.py b/scripts/release.py index f49617e3ae3..36d47c51924 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1009,7 +1009,6 @@ AUTHOR_MAP = { "252620095+briandevans@users.noreply.github.com": "briandevans", "incharge.automation@gmail.com": "inchargeautomation-lab", "danielrpike9@gmail.com": "Bartok9", - "kuangmi@deeparchi.com": "kuangmi-bit", "96944678+ymylive@users.noreply.github.com": "sweetcornna", "laflamme@illinoisalumni.org": "briancl2", "skozyuk@cruxexperts.com": "CruxExperts", diff --git a/tests/cli/test_cli_status_command.py b/tests/cli/test_cli_status_command.py index 6172c6a7319..ed6fbd7d2b3 100644 --- a/tests/cli/test_cli_status_command.py +++ b/tests/cli/test_cli_status_command.py @@ -32,13 +32,6 @@ def test_status_command_is_available_in_cli_registry(): assert cmd.gateway_only is False -def test_egress_command_is_available_in_cli_registry(): - cmd = resolve_command("egress") - assert cmd is not None - assert cmd.gateway_only is False - assert "status" in cmd.subcommands - - def test_process_command_status_dispatches_without_toggling_status_bar(): cli_obj = _make_cli() @@ -49,20 +42,6 @@ def test_process_command_status_dispatches_without_toggling_status_bar(): assert cli_obj._status_bar_visible is True -def test_process_command_egress_prints_proxy_status(monkeypatch): - cli_obj = _make_cli() - monkeypatch.setattr( - "hermes_cli.proxy_cli.format_status_text", - lambda: "Egress proxy status\nEnabled: no", - ) - - assert cli_obj.process_command("/egress") is True - - cli_obj.console.print.assert_called() - printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list) - assert "Egress proxy status" in printed - - def test_statusbar_still_toggles_visibility(): cli_obj = _make_cli() diff --git a/tests/gateway/test_unknown_command.py b/tests/gateway/test_unknown_command.py index c32f5da1bb1..11413449638 100644 --- a/tests/gateway/test_unknown_command.py +++ b/tests/gateway/test_unknown_command.py @@ -146,36 +146,6 @@ async def test_known_slash_command_not_flagged_as_unknown(monkeypatch): assert "Unknown command" not in result -@pytest.mark.asyncio -async def test_egress_slash_command_reports_proxy_status(monkeypatch): - runner = _make_runner() - monkeypatch.setattr( - "hermes_cli.proxy_cli.format_status_text", - lambda: "Egress proxy status\nEnabled: no", - ) - - result = await runner._handle_message(_make_event("/egress")) - - assert result is not None - assert "Egress proxy status" in result - assert "Unknown command" not in result - - -@pytest.mark.asyncio -async def test_egress_slash_command_reports_proxy_status_while_agent_running(monkeypatch): - runner = _make_runner() - runner._running_agents[build_session_key(_make_source())] = MagicMock() - monkeypatch.setattr( - "hermes_cli.proxy_cli.format_status_text", - lambda: "Egress proxy status\nEnabled: yes", - ) - - result = await runner._handle_message(_make_event("/egress")) - - assert result is not None - assert "Egress proxy status" in result - - @pytest.mark.asyncio async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch): """Telegram autocomplete sends /reload_mcp for the /reload-mcp built-in. diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 21e6b6d60d6..d357a7c89a6 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1143,7 +1143,6 @@ class TestTelegramMenuCommands: assert len(names) == 30 assert hidden > 0 for name in ( - "egress", "debug", "restart", "update", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 7d05bfbc3ba..7797be63177 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2963,18 +2963,6 @@ class TestBuildSchemaFromConfig: assert "options" in entry assert "local" in entry["options"] - def test_proxy_schema_warns_dashboard_users_about_lifecycle(self): - from hermes_cli.web_server import CONFIG_SCHEMA - - entry = CONFIG_SCHEMA["proxy.enabled"] - assert entry["category"] == "security" - assert "Docker-only" in entry["description"] - assert "hermes egress setup" in entry["description"] - - source_entry = CONFIG_SCHEMA["proxy.credential_source"] - assert source_entry["type"] == "select" - assert source_entry["options"] == ["env", "bitwarden"] - def test_empty_prefix_produces_correct_keys(self): from hermes_cli.web_server import _build_schema_from_config test_config = {"model": "test", "nested": {"key": "val"}} diff --git a/tests/test_iron_proxy.py b/tests/test_iron_proxy.py deleted file mode 100644 index 73b798e0fac..00000000000 --- a/tests/test_iron_proxy.py +++ /dev/null @@ -1,2050 +0,0 @@ -"""Hermetic tests for the iron-proxy egress integration. - -Covers the pure-function surface (token mint, mapping discovery, config build, -config + mappings I/O), the binary install path (HTTP downloads + tar -extraction + checksum verification fully mocked), the subprocess lifecycle -(spawn / PID / pid_alive / stop, with subprocess.Popen mocked), and the -docker backend's egress arg builder. - -Live network and the real ``iron-proxy`` binary are NEVER touched. See -``tests/test_iron_proxy_e2e.py`` (gated behind a marker) for the real-binary -smoke test. -""" - -from __future__ import annotations - -import io -import os -import sys -import tarfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from agent.proxy_sources import iron_proxy as ip - - -# --------------------------------------------------------------------------- -# Per-test isolation -# --------------------------------------------------------------------------- - - -@pytest.fixture -def hermes_home(tmp_path, monkeypatch): - """Point HERMES_HOME at a temp dir so install paths don't touch the real $HOME.""" - - home = tmp_path / "hermes" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - # Make sure no stale provider keys influence discovery. - for key in list(os.environ): - if key.endswith("_API_KEY"): - monkeypatch.delenv(key, raising=False) - return home - - -# --------------------------------------------------------------------------- -# Token mint + mapping discovery -# --------------------------------------------------------------------------- - - -def test_mint_proxy_token_has_prefix_and_length(): - t = ip.mint_proxy_token("alpha") - assert t.startswith("alpha-") - assert len(t) >= len("alpha-") + 32 - - -def test_mint_proxy_token_is_random(): - a = ip.mint_proxy_token("x") - b = ip.mint_proxy_token("x") - assert a != b - - -def test_discover_provider_mappings_from_env(hermes_home, monkeypatch): - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-real-1") - monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real-2") - monkeypatch.delenv("MISTRAL_API_KEY", raising=False) - ms = ip.discover_provider_mappings() - names = [m.real_env_name for m in ms] - assert "OPENROUTER_API_KEY" in names - assert "OPENAI_API_KEY" in names - assert "MISTRAL_API_KEY" not in names - - -def test_discover_provider_mappings_explicit_names(hermes_home): - ms = ip.discover_provider_mappings( - available_env_names=["OPENROUTER_API_KEY", "GROQ_API_KEY", "UNKNOWN_KEY"] - ) - names = {m.real_env_name for m in ms} - assert names == {"OPENROUTER_API_KEY", "GROQ_API_KEY"} - # Unknown providers (no entry in _BEARER_PROVIDERS) are skipped, not warned. - - -def test_discover_provider_mappings_empty(hermes_home): - ms = ip.discover_provider_mappings(available_env_names=[]) - assert ms == [] - - -# --------------------------------------------------------------------------- -# Config / mapping serialization -# --------------------------------------------------------------------------- - - -def _sample_mapping(env_name: str = "OPENROUTER_API_KEY") -> ip.TokenMapping: - return ip.TokenMapping( - proxy_token=ip.mint_proxy_token("test"), - real_env_name=env_name, - upstream_hosts=("openrouter.ai", "*.openrouter.ai"), - ) - - -def test_build_proxy_config_shape(tmp_path): - m = _sample_mapping() - ca_crt = tmp_path / "ca.crt" - ca_key = tmp_path / "ca.key" - cfg = ip.build_proxy_config( - mappings=[m], - ca_cert=ca_crt, - ca_key=ca_key, - ) - # Top-level sections — note `dns` is required by iron-proxy even when - # we only use the CONNECT tunnel. - assert set(cfg.keys()) >= {"dns", "proxy", "tls", "transforms", "log"} - # Transforms in expected order - assert [t["name"] for t in cfg["transforms"]] == ["allowlist", "secrets"] - # Allowlist uses `domains:` (iron-proxy schema), not `hosts:` - domains = cfg["transforms"][0]["config"]["domains"] - assert "openrouter.ai" in domains - # Secrets transform encodes our mapping - rules = cfg["transforms"][1]["config"]["secrets"] - assert len(rules) == 1 - rule = rules[0] - # Real secret value is sourced from env at egress time, NOT inlined. - assert rule["source"] == {"type": "env", "var": "OPENROUTER_API_KEY"} - # The proxy token is the replacement target. - assert rule["replace"]["proxy_value"] == m.proxy_token - assert "Authorization" in rule["replace"]["match_headers"] - # Fail-closed: a request to a mapped host without the proxy token must be - # rejected, not forwarded with whatever credential it carried - # (maxpetrusenko P1). iron-proxy's replaceConfig.Require enforces this. - assert rule["replace"]["require"] is True - # Rules list contains one entry per upstream host. - rule_hosts = {r["host"] for r in rule["rules"]} - assert rule_hosts == set(m.upstream_hosts) - # TLS section names the CA paths - assert cfg["tls"]["ca_cert"] == str(ca_crt) - - -def test_build_proxy_config_custom_allowed_hosts(tmp_path): - m = _sample_mapping("OPENAI_API_KEY") - cfg = ip.build_proxy_config( - mappings=[m], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - allowed_hosts=["custom-host.test"], - ) - domains = cfg["transforms"][0]["config"]["domains"] - # Custom allowed_hosts wins as the base; mapping's hosts get appended. - assert "custom-host.test" in domains - assert "openrouter.ai" in domains # comes from the mapping - - -# --------------------------------------------------------------------------- -# Default SSRF deny list (regression: docs promise cloud metadata is denied) -# --------------------------------------------------------------------------- - - -def test_default_deny_cidrs_present_when_unspecified(tmp_path): - """build_proxy_config must emit the default deny list when the caller - passes nothing. The IMDS subnet (169.254.0.0/16) MUST be in the result - or the docs claim that ``upstream_deny_cidrs`` refuses cloud metadata - is a lie.""" - - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - ) - deny = cfg["proxy"]["upstream_deny_cidrs"] - assert "169.254.0.0/16" in deny # IMDS - assert "127.0.0.0/8" in deny # loopback v4 - assert "::1/128" in deny # loopback v6 - assert "10.0.0.0/8" in deny # RFC1918 - assert "172.16.0.0/12" in deny # RFC1918 - assert "192.168.0.0/16" in deny # RFC1918 - - -def test_explicit_empty_deny_cidrs_disables_default(tmp_path): - """Explicit ``[]`` opts out of the default deny list — needed by - hermetic tests that want to talk to a loopback upstream.""" - - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - upstream_deny_cidrs=[], - ) - assert cfg["proxy"]["upstream_deny_cidrs"] == [] - - -def test_wizard_rendered_yaml_contains_deny_list(hermes_home, tmp_path): - """End-to-end: cmd_setup writes proxy.yaml; the rendered file must - contain the deny list because the wizard now passes the operator's - config-level setting (None → default) through to build_proxy_config.""" - - # Simulate the wizard's call shape (matches proxy_cli.cmd_setup). - state = ip._proxy_state_dir() - (state / "ca.crt").write_text("fake-ca") - (state / "ca.key").write_text("fake-key") - proxy_yaml = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=state / "ca.crt", - ca_key=state / "ca.key", - # The wizard passes ``upstream_deny_cidrs`` from the config; when - # the operator hasn't set anything, that's None and we get the - # safe default below. - upstream_deny_cidrs=None, - ) - out = ip.write_proxy_config(proxy_yaml) - text = out.read_text(encoding="utf-8") - assert "169.254.0.0/16" in text - - -# --------------------------------------------------------------------------- -# Bind policy (regression: must not bind 0.0.0.0) -# --------------------------------------------------------------------------- - - -def test_default_bind_is_loopback_not_zero_zero(tmp_path): - """The sandbox-facing listeners must NOT be ``0.0.0.0:PORT`` or - ``:PORT`` (latter is INADDR_ANY).""" - - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - tunnel_port=12345, - http_listen=["127.0.0.1:12345"], # explicit so test is deterministic - ) - # tunnel_listen is the CONNECT/MITM listener sandboxes hit via - # HTTPS_PROXY; http_listen is the plain-HTTP forward on port+1. - assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:12345" - assert cfg["proxy"]["http_listen"] == "127.0.0.1:12346" - for key in ("tunnel_listen", "http_listen", "https_listen"): - val = cfg["proxy"][key] - # Sentinel: confirm we didn't accidentally serialize a bare-port - # form like ":12345" (that's INADDR_ANY). - assert not val.startswith(":") - assert "0.0.0.0" not in val - # iron-proxy v0.39 doesn't support http_listens (plural). We - # deliberately do NOT emit that key — re-emitting it would cause - # the daemon to fail YAML unmarshal at start time. - assert "http_listens" not in cfg["proxy"], ( - "iron-proxy v0.39 rejects http_listens (plural); only the " - "singular http_listen string is accepted by the binary" - ) - - -def test_default_bind_uses_docker_bridge_on_linux(tmp_path, monkeypatch): - """When http_listen isn't passed AND we're on Linux, the singular - http_listen field is the DOCKER BRIDGE bind — not loopback. - iron-proxy v0.39 only supports one bind per daemon process, and on - Linux ``host.docker.internal:host-gateway`` resolves to the bridge - gateway (172.17.0.1 by default), which a loopback-only daemon never - answers. Sandboxes must be able to reach the proxy from the - container's vantage point.""" - - monkeypatch.setattr(ip.platform, "system", lambda: "Linux") - monkeypatch.setattr(ip, "_detect_docker_bridge_ip", lambda: "172.17.0.1") - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - tunnel_port=9090, - ) - # The sandbox-facing CONNECT/MITM listener binds the bridge gateway — - # reachable from containers via host.docker.internal. Plain-HTTP - # forward listener rides on port+1, same host. - assert cfg["proxy"]["tunnel_listen"] == "172.17.0.1:9090" - assert cfg["proxy"]["http_listen"] == "172.17.0.1:9091" - # No http_listens (plural) — v0.39 rejects that key. - assert "http_listens" not in cfg["proxy"] - - -def test_default_bind_falls_back_to_loopback_without_bridge(tmp_path, monkeypatch): - """On Linux without a detectable docker0 bridge (docker not - installed / not running), fall back to loopback rather than - refusing to bind.""" - - monkeypatch.setattr(ip.platform, "system", lambda: "Linux") - monkeypatch.setattr(ip, "_detect_docker_bridge_ip", lambda: None) - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - tunnel_port=9090, - ) - assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:9090" - assert cfg["proxy"]["http_listen"] == "127.0.0.1:9091" - - -def test_default_bind_is_loopback_on_macos(tmp_path, monkeypatch): - """On Docker Desktop platforms host.docker.internal routes to the - host via VPNkit, so loopback is reachable from containers and is - the least-exposed bind.""" - - monkeypatch.setattr(ip.platform, "system", lambda: "Darwin") - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - tunnel_port=9090, - ) - assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:9090" - assert cfg["proxy"]["http_listen"] == "127.0.0.1:9091" - - -def test_metrics_listener_pinned_to_loopback_ephemeral(tmp_path): - """iron-proxy v0.39's default metrics_listen is ``:9090``, which - collides with our default tunnel_port=9090. build_proxy_config MUST - explicitly pin metrics.listen to ``127.0.0.1:0`` so the bind - collision can never happen at start time.""" - - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - tunnel_port=9090, - ) - assert cfg["metrics"]["listen"] == "127.0.0.1:0" - - -# --------------------------------------------------------------------------- -# audit_log file pre-creation (parameter still accepted; v0.39 doesn't -# wire it into the binary config but ensure_audit_log() still creates -# the file at 0o600 as a logrotate / monitoring sentinel) -# --------------------------------------------------------------------------- - - -def test_audit_log_kwarg_does_not_inject_audit_path_v039(tmp_path): - """v0.39 of iron-proxy rejects ``log.audit_path`` (not a struct - field). build_proxy_config still accepts the audit_log kwarg for - forward compatibility but MUST NOT emit it into the rendered yaml - until the upstream binary supports it. See the kwarg's docstring - for the upgrade path.""" - - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - audit_log=tmp_path / "audit.log", - ) - assert "audit_path" not in cfg["log"], ( - "iron-proxy v0.39 has no log.audit_path field; emitting it " - "causes 'field audit_path not found in type config.Log' at " - "daemon start. ensure_audit_log() still creates the file as " - "an operator-facing logrotate target." - ) - - -def test_audit_log_omitted_when_caller_passes_none(tmp_path): - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - audit_log=None, - ) - assert "audit_path" not in cfg["log"] - - -def test_write_and_load_mappings_roundtrip(hermes_home): - ms = [_sample_mapping("OPENROUTER_API_KEY"), _sample_mapping("OPENAI_API_KEY")] - path = ip.write_mappings(ms) - assert path.exists() - loaded = ip.load_mappings() - assert len(loaded) == 2 - assert {m.real_env_name for m in loaded} == {"OPENROUTER_API_KEY", "OPENAI_API_KEY"} - # Tokens preserved - assert loaded[0].proxy_token == ms[0].proxy_token - - -def test_load_mappings_handles_missing_file(hermes_home): - assert ip.load_mappings() == [] - - -def test_load_mappings_handles_corrupt_json(hermes_home): - state = ip._proxy_state_dir() - (state / "mappings.json").write_text("{not json", encoding="utf-8") - assert ip.load_mappings() == [] - - -def test_write_proxy_config_serializes_yaml(hermes_home, tmp_path): - ca_crt = tmp_path / "ca.crt" - ca_key = tmp_path / "ca.key" - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=ca_crt, - ca_key=ca_key, - ) - out = ip.write_proxy_config(cfg) - assert out.exists() - text = out.read_text(encoding="utf-8") - assert "tunnel_listen" in text - assert f"ca_cert: {ca_crt}" in text - # The rendered config embeds proxy token values — it must land at - # 0o600, and (TOCTOU) must never be transiently world-readable - # between the atomic replace and the chmod. We chmod the temp file - # before the replace, so the final file is 0o600 from first byte. - import os as _os - mode = _os.stat(out).st_mode & 0o777 - assert mode == 0o600, f"proxy.yaml perms {oct(mode)}, expected 0o600" - mappings_out = ip.write_mappings([_sample_mapping()]) - mmode = _os.stat(mappings_out).st_mode & 0o777 - assert mmode == 0o600, f"mappings.json perms {oct(mmode)}, expected 0o600" - - -# --------------------------------------------------------------------------- -# Token-preservation on re-setup (regression: clobbered live sandboxes) -# --------------------------------------------------------------------------- - - -def test_merge_mappings_preserves_existing_tokens(): - """Re-running setup must not invalidate tokens baked into already- - running sandboxes. ``merge_mappings`` keeps the prior token for any - provider that's in both lists.""" - - existing = [ - ip.TokenMapping( - proxy_token="hermes-proxy-original-12345", - real_env_name="OPENROUTER_API_KEY", - upstream_hosts=("openrouter.ai",), - ), - ] - discovered = ip.discover_provider_mappings( - available_env_names=["OPENROUTER_API_KEY", "OPENAI_API_KEY"] - ) - merged = ip.merge_mappings(existing=existing, discovered=discovered) - by_name = {m.real_env_name: m for m in merged} - # Original token preserved. - assert by_name["OPENROUTER_API_KEY"].proxy_token == "hermes-proxy-original-12345" - # New provider got a fresh token. - assert by_name["OPENAI_API_KEY"].proxy_token != "hermes-proxy-original-12345" - # Both providers in the result. - assert set(by_name) == {"OPENROUTER_API_KEY", "OPENAI_API_KEY"} - - -def test_merge_mappings_drops_providers_removed_from_env(): - """When a provider is in `existing` but not in `discovered`, it must - be dropped from the result — the operator removed the env var.""" - - existing = [ - ip.TokenMapping( - proxy_token="stale", real_env_name="OPENROUTER_API_KEY", - upstream_hosts=("openrouter.ai",), - ), - ] - discovered = ip.discover_provider_mappings( - available_env_names=["OPENAI_API_KEY"] - ) - merged = ip.merge_mappings(existing=existing, discovered=discovered) - names = {m.real_env_name for m in merged} - assert names == {"OPENAI_API_KEY"} - - -def test_merge_mappings_rotate_mints_fresh_tokens(): - """``rotate=True`` rolls every token regardless of overlap. The - --rotate-tokens flag uses this.""" - - existing = [ - ip.TokenMapping( - proxy_token="hermes-proxy-original-12345", - real_env_name="OPENROUTER_API_KEY", - upstream_hosts=("openrouter.ai",), - ), - ] - discovered = ip.discover_provider_mappings( - available_env_names=["OPENROUTER_API_KEY"] - ) - merged = ip.merge_mappings(existing=existing, discovered=discovered, rotate=True) - assert merged[0].proxy_token != "hermes-proxy-original-12345" - - -# --------------------------------------------------------------------------- -# Uncovered provider detection (regression: signature-auth providers bypass) -# --------------------------------------------------------------------------- - - -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 "AWS_ACCESS_KEY_ID" in uncovered - assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered - - -def test_uncovered_providers_explicit_names_empty(): - assert ip.discover_uncovered_providers(available_env_names=[]) == [] - - -def test_uncovered_providers_skips_bearer_providers(hermes_home, monkeypatch): - """OPENROUTER_API_KEY etc. are bearer providers — they should NOT - appear in the uncovered list.""" - - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") - uncovered = ip.discover_uncovered_providers() - 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 -# --------------------------------------------------------------------------- - - -def test_find_iron_proxy_returns_none_when_missing(hermes_home, monkeypatch): - monkeypatch.setattr("shutil.which", lambda name: None) - assert ip.find_iron_proxy(install_if_missing=False) is None - - -def test_find_iron_proxy_returns_managed_first(hermes_home, monkeypatch): - managed = ip._hermes_bin_dir() / ip._platform_binary_name() - managed.parent.mkdir(parents=True, exist_ok=True) - managed.write_bytes(b"#!/bin/sh\necho ok\n") - managed.chmod(0o755) - # Even with a system one on PATH, the managed copy should win. - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/iron-proxy") - assert ip.find_iron_proxy() == managed - - -def _make_fake_tar(binary_name: str, payload: bytes = b"#!/bin/sh\necho ok\n") -> bytes: - """Build a tar.gz with one file at the root, named ``binary_name``.""" - - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - info = tarfile.TarInfo(name=binary_name) - info.size = len(payload) - info.mode = 0o755 - tf.addfile(info, io.BytesIO(payload)) - return buf.getvalue() - - -def test_install_iron_proxy_verifies_checksum_and_extracts(hermes_home, monkeypatch): - fake_payload = _make_fake_tar(ip._platform_binary_name()) - import hashlib - - expected_sha = hashlib.sha256(fake_payload).hexdigest() - asset_name = ip._platform_asset_name() - checksum_text = f"{expected_sha} {asset_name}\nffff other-asset.tar.gz\n" - - def fake_download(url: str, dest: Path) -> None: - if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): - dest.write_text(checksum_text) - else: - dest.write_bytes(fake_payload) - - monkeypatch.setattr(ip, "_http_download", fake_download) - target = ip.install_iron_proxy() - assert target.exists() - assert target.read_bytes() == b"#!/bin/sh\necho ok\n" - # Executable bit is set - assert os.access(target, os.X_OK) - - -def test_install_iron_proxy_rejects_bad_checksum(hermes_home, monkeypatch): - fake_payload = _make_fake_tar(ip._platform_binary_name()) - asset_name = ip._platform_asset_name() - bad_text = f"deadbeef {asset_name}\n" - - def fake_download(url: str, dest: Path) -> None: - if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): - dest.write_text(bad_text) - else: - dest.write_bytes(fake_payload) - - monkeypatch.setattr(ip, "_http_download", fake_download) - with pytest.raises(RuntimeError, match="Checksum mismatch"): - ip.install_iron_proxy() - - -def test_install_iron_proxy_rejects_missing_checksum_entry(hermes_home, monkeypatch): - fake_payload = _make_fake_tar(ip._platform_binary_name()) - - def fake_download(url: str, dest: Path) -> None: - if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): - dest.write_text("aaaa some-other-file.tar.gz\n") - else: - dest.write_bytes(fake_payload) - - monkeypatch.setattr(ip, "_http_download", fake_download) - with pytest.raises(RuntimeError, match="No checksum entry"): - ip.install_iron_proxy() - - -# ── GPG release-signature verification (maxpetrusenko P1) ──────────────────── - -def test_verify_checksums_signature_skips_without_gpg(hermes_home, monkeypatch, tmp_path): - """No gpg on PATH → degrade gracefully (return False), do not raise.""" - monkeypatch.setattr(ip.shutil, "which", lambda name: None) - cks = tmp_path / "checksums.txt" - cks.write_text("abc iron-proxy.tar.gz\n") - assert ip._verify_checksums_signature(tmp_path, cks) is False - - -def test_verify_checksums_signature_skips_when_sig_assets_missing(hermes_home, monkeypatch, tmp_path): - """gpg present but the release ships no .asc assets → degrade, don't raise.""" - monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) - - def fail_download(url: str, dest: Path) -> None: - raise RuntimeError("404 not found") - monkeypatch.setattr(ip, "_http_download", fail_download) - cks = tmp_path / "checksums.txt" - cks.write_text("abc iron-proxy.tar.gz\n") - assert ip._verify_checksums_signature(tmp_path, cks) is False - - -def test_verify_checksums_signature_raises_on_bad_signature(hermes_home, monkeypatch, tmp_path): - """A present-but-INVALID signature is a tamper signal → must raise.""" - monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) - monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc")) - - class _R: - def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD signature" - def fake_run(cmd, **kw): - # import succeeds (rc 0), verify fails (rc 1) - return _R(0) if "--import" in cmd else _R(1) - monkeypatch.setattr(ip.subprocess, "run", fake_run) - - cks = tmp_path / "checksums.txt" - cks.write_text("abc iron-proxy.tar.gz\n") - with pytest.raises(RuntimeError, match="failed GPG signature verification"): - ip._verify_checksums_signature(tmp_path, cks) - - -def test_verify_checksums_signature_passes_on_good_signature(hermes_home, monkeypatch, tmp_path): - """Valid signature → returns True.""" - monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) - monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc")) - - class _R: - def __init__(self, rc): self.returncode = rc; self.stderr = b"" - monkeypatch.setattr(ip.subprocess, "run", lambda cmd, **kw: _R(0)) - - cks = tmp_path / "checksums.txt" - cks.write_text("abc iron-proxy.tar.gz\n") - assert ip._verify_checksums_signature(tmp_path, cks) is True - - -def test_install_aborts_on_bad_release_signature(hermes_home, monkeypatch): - """End-to-end: a tampered (bad-signature) release must abort install.""" - fake_payload = _make_fake_tar(ip._platform_binary_name()) - import hashlib - sha = hashlib.sha256(fake_payload).hexdigest() - asset_name = ip._platform_asset_name() - - def fake_download(url: str, dest: Path) -> None: - if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): - dest.write_text(f"{sha} {asset_name}\n") - elif url.endswith(".asc"): - dest.write_bytes(b"-----BEGIN PGP-----\n") - else: - dest.write_bytes(fake_payload) - monkeypatch.setattr(ip, "_http_download", fake_download) - monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) - - class _R: - def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD" - monkeypatch.setattr(ip.subprocess, "run", - lambda cmd, **kw: _R(0) if "--import" in cmd else _R(1)) - - with pytest.raises(RuntimeError, match="GPG signature verification"): - ip.install_iron_proxy() - - -def test_pick_tar_member_rejects_path_traversal(): - """A malicious tar that escapes via '..' must be refused.""" - - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - info = tarfile.TarInfo(name="../iron-proxy") - info.size = 1 - info.mode = 0o755 - tf.addfile(info, io.BytesIO(b"x")) - buf.seek(0) - with tarfile.open(fileobj=buf, mode="r:gz") as tf: - with pytest.raises(RuntimeError, match="Could not find iron-proxy"): - ip._pick_tar_member(tf, "iron-proxy") - - -# --------------------------------------------------------------------------- -# Subprocess lifecycle -# --------------------------------------------------------------------------- - - -def test_get_status_when_nothing_configured(hermes_home): - status = ip.get_status() - assert status.binary_path is None - assert status.config_path is None - assert status.ca_cert_path is None - assert status.pid is None - assert status.listening is False - assert not status.installed - assert not status.configured - - -def test_get_status_with_config_present(hermes_home, monkeypatch): - # Materialize binary, config, and ca cert. - bin_path = ip._hermes_bin_dir() / ip._platform_binary_name() - bin_path.parent.mkdir(parents=True, exist_ok=True) - bin_path.write_bytes(b"") - bin_path.chmod(0o755) - state = ip._proxy_state_dir() - (state / "ca.crt").write_text("fake") - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=state / "ca.crt", - ca_key=state / "ca.key", - tunnel_port=9999, - ) - ip.write_proxy_config(cfg) - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "iron-proxy v0.0.0-test") - - status = ip.get_status() - assert status.installed - assert status.configured - assert status.tunnel_port == 9999 - assert "test" in (status.binary_version or "") - - -def test_stop_proxy_handles_missing_pidfile(hermes_home): - # No pidfile → stop returns False, doesn't raise. - assert ip.stop_proxy() is False - - -def test_stop_proxy_cleans_stale_pidfile(hermes_home, monkeypatch): - pid_file = ip._proxy_state_dir() / "iron-proxy.pid" - pid_file.write_text("999999999") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: False) - assert ip.stop_proxy() is False - assert not pid_file.exists() - - -def test_start_proxy_refuses_without_binary(hermes_home, monkeypatch): - # No binary, auto_install fails → RuntimeError surfaces. - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: None) - state = ip._proxy_state_dir() - (state / "proxy.yaml").write_text("proxy: {}") - with pytest.raises(RuntimeError, match="binary not available"): - ip.start_proxy() - - -def test_start_proxy_refuses_without_config(hermes_home, monkeypatch): - binary = ip._hermes_bin_dir() / ip._platform_binary_name() - binary.parent.mkdir(parents=True, exist_ok=True) - binary.write_bytes(b"") - binary.chmod(0o755) - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary) - with pytest.raises(RuntimeError, match="config not found"): - ip.start_proxy() - - -def test_start_proxy_writes_pidfile_when_alive(hermes_home, monkeypatch): - binary = ip._hermes_bin_dir() / ip._platform_binary_name() - binary.parent.mkdir(parents=True, exist_ok=True) - binary.write_bytes(b"") - binary.chmod(0o755) - state = ip._proxy_state_dir() - (state / "proxy.yaml").write_text("proxy: {}") - - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary) - monkeypatch.setattr(ip, "_STARTUP_GRACE_SECONDS", 0) - - # Pre-stub everything start_proxy's get_status() call will touch — it - # runs INSIDE start_proxy, so by the time Popen is mocked these have - # to already be hermetic. - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - # v3: start_proxy now REQUIRES _port_listening to return True before - # writing the pidfile. The previous version of this test mocked it - # to False and relied on the loop falling through; the new code - # treats fall-through as failure and kills the child. - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "iron-proxy test") - - fake_proc = MagicMock() - fake_proc.pid = 4242 - fake_proc.poll.return_value = None # still alive - - with patch("subprocess.Popen", lambda *a, **k: fake_proc): - status = ip.start_proxy() - assert (state / "iron-proxy.pid").read_text() == "4242" - assert status.pid == 4242 - - -def test_start_proxy_raises_when_immediate_exit(hermes_home, monkeypatch): - binary = ip._hermes_bin_dir() / ip._platform_binary_name() - binary.parent.mkdir(parents=True, exist_ok=True) - binary.write_bytes(b"") - binary.chmod(0o755) - state = ip._proxy_state_dir() - (state / "proxy.yaml").write_text("proxy: {}") - (state / "iron-proxy.log").write_text("bind: address already in use\n") - - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary) - monkeypatch.setattr(ip, "_STARTUP_GRACE_SECONDS", 0) - - fake_proc = MagicMock() - fake_proc.pid = 5151 - fake_proc.poll.return_value = 1 # exited immediately - fake_proc.returncode = 1 - with patch("subprocess.Popen", lambda *a, **k: fake_proc): - with pytest.raises(RuntimeError, match="exited immediately"): - ip.start_proxy() - - -def test_start_proxy_idempotent_when_already_running(hermes_home, monkeypatch): - state = ip._proxy_state_dir() - pid_file = state / "iron-proxy.pid" - pid_file.write_text("12345") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") - # Materialize config so we get past that check (we shouldn't reach it, - # but if the idempotent path regresses we want a clean failure mode). - (state / "proxy.yaml").write_text("proxy: {}") - # Sentinel: subprocess.Popen must NOT be called. - with patch("subprocess.Popen", lambda *a, **k: pytest.fail("should not spawn")): - status = ip.start_proxy() - # Should return without spawning anything. - assert status is not None - - -# --------------------------------------------------------------------------- -# Docker integration -# --------------------------------------------------------------------------- - - -def test_docker_egress_args_empty_when_disabled(hermes_home, monkeypatch): - from tools.environments.docker import _egress_proxy_args_for_docker - - # Default config has proxy.enabled=False; helper should return all empties. - vol, env, host = _egress_proxy_args_for_docker() - assert vol == [] - assert env == {} - assert host == [] - - -def test_docker_egress_args_when_enabled_but_unconfigured_raises(hermes_home, monkeypatch): - from tools.environments.docker import _egress_proxy_args_for_docker - from hermes_cli.config import load_config, save_config - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = True - save_config(cfg) - - # No proxy.yaml exists → enforce_on_docker should raise. - with pytest.raises(RuntimeError, match="not configured"): - _egress_proxy_args_for_docker() - - -def test_docker_egress_args_when_unconfigured_no_enforce(hermes_home, monkeypatch): - from tools.environments.docker import _egress_proxy_args_for_docker - from hermes_cli.config import load_config, save_config - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = False - save_config(cfg) - - # Without enforcement, missing config returns empties (warning only). - vol, env, host = _egress_proxy_args_for_docker() - assert vol == [] - assert env == {} - assert host == [] - - -def test_docker_egress_args_full_path(hermes_home, monkeypatch): - """Wire up everything (config, CA, mappings, fake running proxy) and - verify the docker helper emits the right mounts and env.""" - - from tools.environments.docker import _egress_proxy_args_for_docker - from hermes_cli.config import load_config, save_config - - # Materialize config, CA, mappings. - state = ip._proxy_state_dir() - ca = state / "ca.crt" - ca.write_text("fake-ca") - (state / "ca.key").write_text("fake-key") - mapping = _sample_mapping("OPENROUTER_API_KEY") - proxy_cfg = ip.build_proxy_config( - mappings=[mapping], ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, - ) - ip.write_proxy_config(proxy_cfg) - ip.write_mappings([mapping]) - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = True - save_config(cfg) - - # Fake running proxy. - (state / "iron-proxy.pid").write_text("99999") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - - vol, env, host = _egress_proxy_args_for_docker() - # CA mount present and in -v form - assert "-v" in vol - assert any("hermes-egress-ca.crt" in arg for arg in vol) - # Env contains both casings of HTTPS_PROXY and the CA env vars - assert env["HTTPS_PROXY"].endswith(":9090") - assert env["https_proxy"] == env["HTTPS_PROXY"] - assert env["REQUESTS_CA_BUNDLE"].endswith("hermes-egress-ca.crt") - assert env["NODE_EXTRA_CA_CERTS"] == env["REQUESTS_CA_BUNDLE"] - # NO_PROXY excludes loopback - assert "127.0.0.1" in env["NO_PROXY"] - # Per-mapping proxy token is surfaced under both the standard provider env - # name (so existing SDKs work without egress-specific code) and the - # introspection name. - assert env["OPENROUTER_API_KEY"] == mapping.proxy_token - assert env["HERMES_PROXY_TOKEN_OPENROUTER_API_KEY"] == mapping.proxy_token - # Linux host-gateway mapping - assert host == ["--add-host", "host.docker.internal:host-gateway"] - - -def test_docker_egress_fingerprint_changes_with_tokens(hermes_home, monkeypatch): - """Persistent Docker container reuse must not attach to a container that - was created before egress, before a token rotation, or with a different CA - mount. The label hash is what forces a fresh container in those cases.""" - - from tools.environments.docker import _egress_reuse_fingerprint - - first = _egress_reuse_fingerprint( - ["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"], - {"OPENROUTER_API_KEY": "token-a", "HTTPS_PROXY": "http://h:9090"}, - ["--add-host", "host.docker.internal:host-gateway"], - ) - second = _egress_reuse_fingerprint( - ["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"], - {"OPENROUTER_API_KEY": "token-b", "HTTPS_PROXY": "http://h:9090"}, - ["--add-host", "host.docker.internal:host-gateway"], - ) - - assert first - assert second - assert first != second - - -# --------------------------------------------------------------------------- -# Platform asset name resolution -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "system,machine,expected_substring", - [ - ("Linux", "x86_64", "linux_amd64"), - ("Linux", "aarch64", "linux_arm64"), - ("Darwin", "arm64", "darwin_arm64"), - ("Darwin", "x86_64", "darwin_amd64"), - ], -) -def test_platform_asset_name(monkeypatch, system, machine, expected_substring): - monkeypatch.setattr("platform.system", lambda: system) - monkeypatch.setattr("platform.machine", lambda: machine) - assert expected_substring in ip._platform_asset_name() - - -def test_platform_asset_name_rejects_windows(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Windows") - monkeypatch.setattr("platform.machine", lambda: "AMD64") - with pytest.raises(RuntimeError, match="does not ship native Windows"): - ip._platform_asset_name() - - -# --------------------------------------------------------------------------- -# Subprocess env minimization (regression: host secrets leaked to proxy) -# --------------------------------------------------------------------------- - - -def test_subprocess_env_strips_unrelated_secrets(hermes_home, monkeypatch): - """``_build_proxy_subprocess_env`` must NOT carry every host secret - over to the proxy. /proc//environ on the proxy would otherwise - expose all of them to same-uid local processes.""" - - # Unrelated env vars that should NOT propagate. - monkeypatch.setenv("MY_PRIVATE_TOKEN", "should-not-leak") - monkeypatch.setenv("DATABASE_URL", "postgres://very-private") - monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-very-secret") - # Provider keys that ARE in load_mappings should propagate. - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-real") - ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) - - env = ip._build_proxy_subprocess_env() - assert "MY_PRIVATE_TOKEN" not in env - assert "DATABASE_URL" not in env - assert "SLACK_BOT_TOKEN" not in env - assert env.get("OPENROUTER_API_KEY") == "sk-or-real" - - -def test_subprocess_env_strips_proxy_recursion_vars(hermes_home, monkeypatch): - """HTTPS_PROXY etc. in the parent env would otherwise recurse iron-proxy - through itself (or send its traffic through a corporate proxy).""" - - monkeypatch.setenv("HTTPS_PROXY", "http://corporate:3128") - monkeypatch.setenv("HTTP_PROXY", "http://corporate:3128") - monkeypatch.setenv("ALL_PROXY", "socks5://corporate:1080") - env = ip._build_proxy_subprocess_env() - assert "HTTPS_PROXY" not in env - assert "https_proxy" not in env - assert "HTTP_PROXY" not in env - assert "ALL_PROXY" not in env - - -def test_subprocess_env_keeps_infrastructure_vars(hermes_home, monkeypatch): - """PATH / HOME / locale must propagate or the child can't even find - its libs.""" - - monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin") - monkeypatch.setenv("HOME", "/home/test") - monkeypatch.setenv("LANG", "C.UTF-8") - env = ip._build_proxy_subprocess_env() - assert env.get("PATH") == "/usr/local/bin:/usr/bin" - assert env.get("HOME") == "/home/test" - assert env.get("LANG") == "C.UTF-8" - - -# --------------------------------------------------------------------------- -# CA generation TOCTOU (regression: 0o600 only set AFTER copy) -# --------------------------------------------------------------------------- - - -def test_ca_key_created_with_0o600(hermes_home, monkeypatch): - """The CA private key must NEVER exist on disk with default umask - permissions, even transiently. Fix: open with explicit mode=0o600 - so the very first byte is written under tight perms.""" - - # ensure_ca_cert shells out to openssl; mock the subprocess.run calls - # so we don't need openssl on the test host AND don't depend on its - # output format. - def fake_run(args, **kwargs): - # First call: genrsa → -out is at args[-2] - if args[1] == "genrsa": - out = args[-2] - Path(out).write_bytes(b"-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n") - elif args[1] == "req": - # Find -out path - i = args.index("-out") - Path(args[i + 1]).write_bytes(b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") - result = MagicMock() - result.returncode = 0 - return result - - monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/openssl" if name == "openssl" else None) - monkeypatch.setattr(ip.subprocess, "run", fake_run) - - ca_crt, ca_key = ip.ensure_ca_cert() - assert ca_key.exists() - mode = ca_key.stat().st_mode & 0o777 - assert mode == 0o600, f"CA key has perms {oct(mode)}, expected 0o600" - - -# --------------------------------------------------------------------------- -# Audit log permissions (regression: depended on umask) -# --------------------------------------------------------------------------- - - -def test_ensure_audit_log_creates_with_0o600(hermes_home, tmp_path): - audit = tmp_path / "audit.log" - ip.ensure_audit_log(audit) - assert audit.exists() - mode = audit.stat().st_mode & 0o777 - assert mode == 0o600 - - -def test_ensure_audit_log_tightens_existing_perms(hermes_home, tmp_path): - audit = tmp_path / "audit.log" - audit.write_text("preexisting content\n") - os.chmod(audit, 0o644) - ip.ensure_audit_log(audit) - mode = audit.stat().st_mode & 0o777 - assert mode == 0o600 - - -# --------------------------------------------------------------------------- -# State dir hardening (regression: world-traversable on multi-user hosts) -# --------------------------------------------------------------------------- - - -def test_proxy_state_dir_is_0o700(hermes_home): - state = ip._proxy_state_dir() - mode = state.stat().st_mode & 0o777 - assert mode == 0o700 - - -def test_proxy_state_dir_ro_does_not_create(hermes_home): - """_proxy_state_dir_ro is for read-only callers — it must NOT - materialize the dir. Pure-status code paths shouldn't have the - side-effect of creating ~/.hermes/proxy/.""" - - # Sanity: rw path creates it. - rw = ip._proxy_state_dir() - assert rw.exists() - # Remove it and confirm the ro path doesn't recreate. - import shutil as _shutil - _shutil.rmtree(str(rw)) - assert not rw.exists() - ro = ip._proxy_state_dir_ro() - assert not ro.exists() - # The path string is the same as the rw one. - assert ro == rw - - -# --------------------------------------------------------------------------- -# Mappings clobber refused when corrupt (regression: silent 403s) -# --------------------------------------------------------------------------- - - -def test_docker_egress_args_raises_on_empty_mappings(hermes_home, monkeypatch): - """If mappings.json is missing / corrupt / empty AND - enforce_on_docker is true, refuse to start the sandbox rather than - silently mounting an unusable proxy config.""" - - from tools.environments.docker import _egress_proxy_args_for_docker - from hermes_cli.config import load_config, save_config - - state = ip._proxy_state_dir() - (state / "ca.crt").write_text("fake-ca") - (state / "ca.key").write_text("fake-key") - proxy_cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=state / "ca.crt", ca_key=state / "ca.key", tunnel_port=9090, - ) - ip.write_proxy_config(proxy_cfg) - # Note: we deliberately do NOT write mappings.json — that's the - # bug-class this test guards against. - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = True - save_config(cfg) - - (state / "iron-proxy.pid").write_text("99999") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - - with pytest.raises(RuntimeError, match="mappings.json is empty or"): - _egress_proxy_args_for_docker() - - -# --------------------------------------------------------------------------- -# CA missing → enforce_on_docker semantics (regression: silent fail-open) -# --------------------------------------------------------------------------- - - -def test_docker_egress_args_raises_when_ca_vanishes(hermes_home, monkeypatch): - """status.configured was True at check time but the CA file - disappeared between then and now (e.g. operator manually deleted - ~/.hermes/proxy/ca.crt). enforce_on_docker=True must refuse.""" - - from tools.environments.docker import _egress_proxy_args_for_docker - from hermes_cli.config import load_config, save_config - - state = ip._proxy_state_dir() - ca = state / "ca.crt" - ca.write_text("fake-ca") - (state / "ca.key").write_text("fake-key") - proxy_cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, - ) - ip.write_proxy_config(proxy_cfg) - ip.write_mappings([_sample_mapping()]) - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = True - save_config(cfg) - - (state / "iron-proxy.pid").write_text("99999") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - - # Build a fake status: configured=True (because both path fields are - # set), but ca_cert_path.exists() is False — simulating the race - # where the CA file vanished between get_status() and the - # exists() recheck inside _egress_proxy_args_for_docker. - fake_status = ip.ProxyStatus( - binary_path=state / "fake-bin", # truthy - config_path=state / "proxy.yaml", - ca_cert_path=state / "missing-ca.crt", # points at nonexistent path - pid=99999, - listening=True, - tunnel_port=9090, - ) - # ProxyStatus.configured returns True iff config_path AND ca_cert_path - # both exist. We need configured=True but the second exists() check - # in docker.py to return False — force that by writing a placeholder - # config_path that exists and pointing ca_cert_path at a missing file. - (state / "proxy.yaml").write_text("# fake") - # ProxyStatus.configured: config_path.exists() and ca_cert_path.exists(). - # Make ca_cert_path .exists() True for the configured check but the - # explicit .exists() recheck path in docker.py reads the same Path, - # which is missing — so we wrap. - class _CAStub: - """Path-like that toggles .exists() so configured=True but the - defensive recheck in docker.py returns False.""" - _calls = 0 - def __init__(self, real: Path): - self._real = real - def __str__(self): - return str(self._real) - @property - def parent(self): - return self._real.parent - def exists(self): - type(self)._calls += 1 - # First call: configured property check → say yes. - # Second call: docker.py defensive recheck → say no. - return type(self)._calls == 1 - fake_status.ca_cert_path = _CAStub(state / "missing-ca.crt") # type: ignore[assignment] - monkeypatch.setattr(ip, "get_status", lambda: fake_status) - - with pytest.raises(RuntimeError, match="CA cert vanished"): - _egress_proxy_args_for_docker() - - -# --------------------------------------------------------------------------- -# Docker env collision detection (regression: docker_env silently bypassed proxy) -# --------------------------------------------------------------------------- - - -def test_docker_env_collision_with_proxy_raises_when_enforce(hermes_home, monkeypatch): - """Setting docker_env: {HTTPS_PROXY: ''} in config.yaml with - enforce_on_docker=true must fail-loud rather than silently inverting - the egress isolation.""" - - from tools.environments.docker import DockerEnvironment - from hermes_cli.config import load_config, save_config - - # Set up a fully-running proxy. - state = ip._proxy_state_dir() - ca = state / "ca.crt" - ca.write_text("fake-ca") - (state / "ca.key").write_text("fake-key") - proxy_cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, - ) - ip.write_proxy_config(proxy_cfg) - ip.write_mappings([_sample_mapping()]) - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = True - save_config(cfg) - (state / "iron-proxy.pid").write_text("99999") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - - # Mock the docker availability check so we never shell out. - monkeypatch.setattr( - "tools.environments.docker._ensure_docker_available", lambda: None, - ) - # Mock find_docker so the resolved docker exe isn't probed. - monkeypatch.setattr( - "tools.environments.docker.find_docker", lambda: "/bin/true", - ) - # Mock subprocess.run so we don't actually run `docker run`. We - # only need the constructor to get past the env merge logic. - monkeypatch.setattr( - "tools.environments.docker.subprocess.run", - lambda *a, **k: MagicMock(stdout="abc123\n", returncode=0), - ) - # init_session is the second outbound subprocess we don't care about. - monkeypatch.setattr( - "tools.environments.docker.DockerEnvironment.init_session", - lambda self: None, - ) - - # The collision: user sets HTTPS_PROXY to empty string in docker_env. - with pytest.raises(RuntimeError, match="overrides egress-proxy variables"): - DockerEnvironment( - image="busybox", - env={"HTTPS_PROXY": ""}, # the collision - ) - - -# --------------------------------------------------------------------------- -# v3 round: bridge-IP parser hardening (P1 #1) -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "bogus_ip", - [ - "0.0.0.0", # INADDR_ANY — must NEVER bind here - "127.0.0.1", # loopback — reject (we bind loopback explicitly) - "224.0.0.1", # multicast - "240.0.0.0", # reserved - "169.254.0.1", # link-local / IMDS — never a real bridge - "8.8.8.8", # public — never a docker bridge - "999.999.999.999", # garbage that count(.)==3 used to accept - "aa.bb.cc.dd", # alpha garbage - ], -) -def test_detect_docker_bridge_ip_rejects_dangerous(monkeypatch, bogus_ip): - """The parser must reject anything that isn't plausibly a docker - bridge IP. Previously ``ip.count('.') == 3`` would accept all of - these and re-open the LAN exposure the bind-policy fix closed.""" - - fake_stdout = ( - f"3: docker0 inet {bogus_ip}/16 brd 172.17.255.255 scope global docker0\\\n" - " valid_lft forever preferred_lft forever" - ) - - def fake_run(cmd, **kw): - from unittest.mock import MagicMock - m = MagicMock() - m.returncode = 0 - m.stdout = fake_stdout - return m - - monkeypatch.setattr(ip.subprocess, "run", fake_run) - assert ip._detect_docker_bridge_ip() is None - - -def test_detect_docker_bridge_ip_accepts_typical(monkeypatch): - fake_stdout = ( - "3: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0\\\n" - " valid_lft forever preferred_lft forever" - ) - - def fake_run(cmd, **kw): - from unittest.mock import MagicMock - m = MagicMock() - m.returncode = 0 - m.stdout = fake_stdout - return m - - monkeypatch.setattr(ip.subprocess, "run", fake_run) - assert ip._detect_docker_bridge_ip() == "172.17.0.1" - - -def test_detect_docker_bridge_ip_handles_missing_ip_command(monkeypatch): - """No ``ip`` on PATH (or other OSError) returns None cleanly.""" - - def boom(*a, **k): - raise OSError("no such file") - - monkeypatch.setattr(ip.subprocess, "run", boom) - assert ip._detect_docker_bridge_ip() is None - - -# --------------------------------------------------------------------------- -# v3: default deny-list adjacency (P2 IPv4-mapped-v6 + CGNAT) -# --------------------------------------------------------------------------- - - -def test_default_deny_includes_ipv4_mapped_v6(tmp_path): - cfg = ip.build_proxy_config( - mappings=[_sample_mapping()], - ca_cert=tmp_path / "ca.crt", - ca_key=tmp_path / "ca.key", - ) - deny = cfg["proxy"]["upstream_deny_cidrs"] - assert "::ffff:0:0/96" in deny - assert "100.64.0.0/10" in deny # CGNAT - assert "198.18.0.0/15" in deny # RFC2544 benchmark - - -# --------------------------------------------------------------------------- -# Header-auth providers (x-api-key family) — match_headers + aliases -# --------------------------------------------------------------------------- - - -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("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" - - -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-") - - -# --------------------------------------------------------------------------- -# v3: _pid_proc_starttime parser (handles comm with parens, brackets) -# --------------------------------------------------------------------------- - - -def test_pid_proc_starttime_parses_comm_with_parens(tmp_path, monkeypatch): - """``/proc//stat`` 'comm' field can contain spaces and parens - (e.g. a process literally named ``weird) tail``, with the comm - wrapped in the outer parens producing ``(weird) tail)``). The - starttime parser must split from the LAST ')' to skip the comm - correctly, otherwise the field-index math drifts. - - Layout reminder: `` () ... ...`` - where starttime is field 22 (1-indexed). Past the LAST ')' we have - fields 3..N → tail index 0..N-3, so starttime is at tail index 19. - """ - - # Comm contains a ')' character inside the outer parens. The outer - # parens are stripped by the kernel's stat format; we test that - # rfind(')') correctly finds the OUTER closing one. - # Format: pid (comm) state ppid pgrp session tty_nr tpgid flags - # minflt cminflt majflt cmajflt utime stime cutime cstime - # priority nice num_threads itrealvalue starttime ... - fake_stat = ( - "12345 (weird) tail) " # pid + comm-with-paren-and-space - "S 1 1 1 0 -1 4194304 " # state ppid pgrp sess tty tpgid flags - "100 0 0 0 10 5 0 0 " # minflt cminflt majflt cmajflt utime stime cutime cstime - "20 0 1 0 99887766 " # priority nice nthreads itreal STARTTIME - "1234 5678 ...rest..." # vsize rss ... - ) - - real_read_text = Path.read_text - - def fake_read_text(self, *a, **k): - if str(self).startswith("/proc/12345/stat"): - return fake_stat - return real_read_text(self, *a, **k) - - monkeypatch.setattr(Path, "read_text", fake_read_text) - assert ip._pid_proc_starttime(12345) == "99887766" - - -def test_pid_proc_starttime_returns_none_on_missing_proc(monkeypatch): - """Non-Linux hosts or pid not running.""" - - def raise_oserror(self, *a, **k): - raise OSError("no such file") - - monkeypatch.setattr(Path, "read_text", raise_oserror) - assert ip._pid_proc_starttime(99999) is None - - -# --------------------------------------------------------------------------- -# v3: stop_proxy SIGKILL suppression on pid recycle (P3 #5 coverage gap) -# --------------------------------------------------------------------------- - - -def test_stop_proxy_suppresses_sigkill_on_pid_recycle(hermes_home, monkeypatch): - """When _pid_proc_starttime returns different values before and - after the SIGTERM grace window, stop_proxy must NOT issue SIGKILL — - the original pid was recycled to an unrelated process.""" - - state = ip._proxy_state_dir() - (state / "iron-proxy.pid").write_text("12345") - - # _pid_alive: always True (process at this pid keeps appearing alive) - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - - # Starttime changes — first call returns "AAA", second returns "BBB" - # (simulating PID recycle to an unrelated process during the grace). - starttime_responses = iter(["111", "222"]) - monkeypatch.setattr( - ip, "_pid_proc_starttime", - lambda pid: next(starttime_responses, "222"), - ) - - # Sentinel: kill list — if SIGKILL is sent, this gets populated. - kills: list = [] - - def fake_os_kill(pid, sig): - kills.append((pid, sig)) - - monkeypatch.setattr(ip.os, "kill", fake_os_kill) - # Speed up the wait loop. - monkeypatch.setattr(ip.time, "sleep", lambda _: None) - # Make time advance fast. - orig_time = ip.time.time - counter = {"n": 0.0} - - def fake_time(): - counter["n"] += 1.0 # 1 second per call — past deadline immediately - return counter["n"] - - monkeypatch.setattr(ip.time, "time", fake_time) - - result = ip.stop_proxy() - assert result is True - # SIGTERM should fire, SIGKILL should NOT (recycled detection). - sigterm_count = sum(1 for _, s in kills if s == ip.signal.SIGTERM) - sigkill_count = sum(1 for _, s in kills if s == ip._KILL_SIGNAL) - assert sigterm_count == 1 - assert sigkill_count == 0, f"SIGKILL was issued despite pid recycle: {kills}" - - -# --------------------------------------------------------------------------- -# v3: _reset_for_tests actually clears module state (P3 #1) -# --------------------------------------------------------------------------- - - -def test_reset_for_tests_clears_version_cache_and_nonce(): - """_reset_for_tests must clear _VERSION_CACHE and _proxy_nonce so - in-process callers don't see leakage between tests.""" - - ip._VERSION_CACHE["dummy"] = "v0.0.0-fake" - ip._proxy_nonce = "fake-nonce-12345" - ip._reset_for_tests() - assert ip._VERSION_CACHE == {} - assert ip._proxy_nonce is None - - -# --------------------------------------------------------------------------- -# v3: version cache doesn't poison on empty stdout (P2 _VERSION_CACHE bug B) -# --------------------------------------------------------------------------- - - -def test_iron_proxy_version_does_not_cache_empty_output(monkeypatch, tmp_path): - """If --version returns 0 with empty stdout (corrupt binary, flag - rename upstream), don't poison the cache — re-probe next call.""" - - binary = tmp_path / "iron-proxy" - binary.write_text("") - - # First call: returns empty output. - def fake_run_empty(cmd, **kw): - from unittest.mock import MagicMock - m = MagicMock() - m.returncode = 0 - m.stdout = "" - m.stderr = "" - return m - - ip._VERSION_CACHE.clear() - monkeypatch.setattr(ip.subprocess, "run", fake_run_empty) - assert ip.iron_proxy_version(binary) == "" - # Should NOT have cached the empty string. - assert str(binary) not in ip._VERSION_CACHE - - -# --------------------------------------------------------------------------- -# v3: NODE_OPTIONS append-merge in docker env (arshkumarsingh #1) -# --------------------------------------------------------------------------- - - -def test_docker_egress_node_options_uses_sentinel(hermes_home, monkeypatch): - """``_egress_proxy_args_for_docker`` should NOT put NODE_OPTIONS in - env_overrides directly; it uses a sentinel key - ``_HERMES_EGRESS_NODE_OPTIONS_APPEND`` so DockerEnvironment can - append-merge with the operator's existing NODE_OPTIONS.""" - - from tools.environments.docker import _egress_proxy_args_for_docker - from hermes_cli.config import load_config, save_config - - state = ip._proxy_state_dir() - ca = state / "ca.crt" - ca.write_text("fake-ca") - (state / "ca.key").write_text("fake-key") - mapping = _sample_mapping("OPENROUTER_API_KEY") - proxy_cfg = ip.build_proxy_config( - mappings=[mapping], ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, - ) - ip.write_proxy_config(proxy_cfg) - ip.write_mappings([mapping]) - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["enforce_on_docker"] = True - save_config(cfg) - - (state / "iron-proxy.pid").write_text("99999") - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) - - _, env, _ = _egress_proxy_args_for_docker() - # The egress dict should contain the sentinel, NOT a raw NODE_OPTIONS. - assert env.get("_HERMES_EGRESS_NODE_OPTIONS_APPEND") == "--use-openssl-ca" - assert "NODE_OPTIONS" not in env, ( - "NODE_OPTIONS in egress env_overrides would clobber the operator's " - "docker_env NODE_OPTIONS — that's exactly the bug arshkumarsingh " - "flagged." - ) - - -# --------------------------------------------------------------------------- -# v3: ensure_audit_log fails loud on OSError (P2 promise mismatch) -# --------------------------------------------------------------------------- - - -def test_ensure_audit_log_raises_on_immutable_parent(tmp_path, monkeypatch): - """When the audit log can't be created with 0o600 (planted symlink, - immutable dir, disk full), raise — silently logging a warning would - let the daemon create the file under default umask, breaking the - privacy promise.""" - - # Aim at a path whose parent doesn't exist — open() will OSError. - audit = tmp_path / "definitely-does-not-exist" / "audit.log" - with pytest.raises(RuntimeError, match="audit log"): - ip.ensure_audit_log(audit) - - -# --------------------------------------------------------------------------- -# v3: persisted nonce roundtrip (stephenschoettler #3 cross-CLI defense) -# --------------------------------------------------------------------------- - - -def test_persisted_nonce_roundtrip(hermes_home, monkeypatch): - """Write the nonce next to the pidfile (simulating one CLI invocation - finishing start_proxy), then verify a fresh _read_persisted_nonce - can pick it up — that's what cross-process _pid_alive uses.""" - - nonce_path = ip._persisted_nonce_path() - nonce_path.parent.mkdir(parents=True, exist_ok=True) - nonce_path.write_text("test-nonce-abc123") - assert ip._read_persisted_nonce() == "test-nonce-abc123" - - -def test_persisted_nonce_returns_none_when_missing(hermes_home): - """No nonce file → None, callers fall back to argv0 basename.""" - assert ip._read_persisted_nonce() is None - - -# --------------------------------------------------------------------------- -# v4 round (GodsBoy follow-up): bind-host-aware liveness probes + -# allow_env_fallback on the partial-secret path -# --------------------------------------------------------------------------- - - -def test_read_http_listen_from_config_returns_host_and_port(hermes_home): - """_read_http_listen_from_config must surface the BIND HOST, not just - the port — on Linux the daemon binds the docker bridge gateway and a - hardcoded loopback probe would report a healthy daemon as dead.""" - - state = ip._proxy_state_dir() - (state / "proxy.yaml").write_text( - "proxy:\n http_listen: 172.17.0.1:9090\n", encoding="utf-8" - ) - assert ip._read_http_listen_from_config() == ("172.17.0.1", 9090) - assert ip._read_tunnel_port_from_config() == 9090 - - -def test_read_http_listen_from_config_missing_file(hermes_home): - assert ip._read_http_listen_from_config() is None - - -def test_get_status_probes_configured_bind_host(hermes_home, monkeypatch): - """get_status must probe the configured bind host (e.g. the docker - bridge IP), not loopback unconditionally.""" - - state = ip._proxy_state_dir() - (state / "proxy.yaml").write_text( - "proxy:\n http_listen: 172.17.0.1:9123\n", encoding="utf-8" - ) - (state / "ca.crt").write_text("cert") - ip._write_pidfile_safely(ip._pidfile(), 99999) - monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: None) - - probed = {} - - def fake_probe(host, port): - probed["host"] = host - probed["port"] = port - return True - - monkeypatch.setattr(ip, "_port_listening", fake_probe) - status = ip.get_status() - assert probed == {"host": "172.17.0.1", "port": 9123} - assert status.listening is True - assert status.tunnel_port == 9123 - - -def test_partial_bitwarden_secrets_honor_allow_env_fallback( - hermes_home, monkeypatch, -): - """The missing-secret branch's own error message tells operators to - set proxy.allow_env_fallback — so the flag must actually work there - (previously only the empty-token branch honored it).""" - - ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host-fallback") - - import agent.secret_sources.bitwarden as bw - monkeypatch.setattr( - bw, "fetch_bitwarden_secrets", lambda **kw: ({}, []), - ) - monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") - bw_cfg = { - "project_id": "proj", - "access_token_env": "BWS_ACCESS_TOKEN", - "allow_env_fallback": True, - } - - env = ip._build_proxy_subprocess_env( - refresh_from_bitwarden=True, bitwarden_config=bw_cfg, - ) - # Falls back to the host env value instead of raising. - assert env.get("OPENROUTER_API_KEY") == "sk-host-fallback" - - -def test_partial_bitwarden_secrets_raise_without_fallback( - hermes_home, monkeypatch, -): - """Strict default: missing BWS secrets raise.""" - - ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host") - - import agent.secret_sources.bitwarden as bw - monkeypatch.setattr( - bw, "fetch_bitwarden_secrets", lambda **kw: ({}, []), - ) - monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") - bw_cfg = {"project_id": "proj", "access_token_env": "BWS_ACCESS_TOKEN"} - - with pytest.raises(RuntimeError, match="did not return secrets"): - ip._build_proxy_subprocess_env( - refresh_from_bitwarden=True, bitwarden_config=bw_cfg, - ) - - -def test_bitwarden_importerror_raise_without_fallback( - hermes_home, monkeypatch, -): - """Strict default: ImportError on BWS module raises when - allow_env_fallback is unset, matching the sibling branches.""" - - ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host") - - # Simulate the BWS SDK not being installed. The lazy import - # ``from agent.secret_sources import bitwarden`` inside - # _build_proxy_subprocess_env resolves through the parent package's - # cached attribute; deleting both the sys.modules entry AND the - # parent-package attribute forces a real import that we intercept. - # - # In addition, block importlib.reload in case the test infra used it. - import agent.secret_sources as ss - monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden", raising=False) - monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden.bws", raising=False) - monkeypatch.delattr(ss, "bitwarden", raising=False) - - # Now block the re-import. ``from agent.secret_sources import - # bitwarden`` resolves to a submodule attribute; setting it to a - # sentinel that raises on attribute access is more reliable than - # trying to intercept __import__ at the C level. - class _MissingBWS: - """Sentinel: accessing any attribute raises ImportError.""" - def __getattr__(self, _name): - raise ImportError("bws SDK not installed") - def __call__(self, *a, **kw): - raise ImportError("bws SDK not installed") - monkeypatch.setattr(ss, "bitwarden", _MissingBWS(), raising=False) - - monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") - bw_cfg = {"project_id": "proj", "access_token_env": "BWS_ACCESS_TOKEN"} - - with pytest.raises(RuntimeError, match="Bitwarden refresh module unavailable"): - ip._build_proxy_subprocess_env( - refresh_from_bitwarden=True, bitwarden_config=bw_cfg, - ) - - -def test_bitwarden_importerror_honor_allow_env_fallback( - hermes_home, monkeypatch, -): - """With allow_env_fallback, an ImportError falls through to host env - instead of raising.""" - - ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host-fallback") - - import agent.secret_sources as ss - monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden", raising=False) - monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden.bws", raising=False) - monkeypatch.delattr(ss, "bitwarden", raising=False) - - class _MissingBWS: - def __getattr__(self, _name): - raise ImportError("bws SDK not installed") - def __call__(self, *a, **kw): - raise ImportError("bws SDK not installed") - monkeypatch.setattr(ss, "bitwarden", _MissingBWS(), raising=False) - - monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") - bw_cfg = { - "project_id": "proj", - "access_token_env": "BWS_ACCESS_TOKEN", - "allow_env_fallback": True, - } - - env = ip._build_proxy_subprocess_env( - refresh_from_bitwarden=True, bitwarden_config=bw_cfg, - ) - assert env.get("OPENROUTER_API_KEY") == "sk-host-fallback" diff --git a/tests/test_iron_proxy_cli.py b/tests/test_iron_proxy_cli.py deleted file mode 100644 index f3a6c9ba8cc..00000000000 --- a/tests/test_iron_proxy_cli.py +++ /dev/null @@ -1,597 +0,0 @@ -"""Unit tests for ``hermes_cli.proxy_cli`` command handlers. - -These tests cover the user-facing CLI surface that was previously -uncovered. We mock the iron_proxy module's side-effect functions -(install / start / stop / discover) and exercise the dispatch + -return-code logic plus the small amount of presentation logic in -each handler (e.g. --from-bitwarden's fail-loud path). -""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from agent.proxy_sources import iron_proxy as ip -from hermes_cli import proxy_cli - - -@pytest.fixture -def hermes_home(tmp_path, monkeypatch): - """Point HERMES_HOME at a temp dir so the wizard doesn't touch the - operator's real config. Also blanks any provider env vars so we - don't accidentally read a real key.""" - - home = tmp_path / "hermes" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - for key in list(os.environ): - if key.endswith("_API_KEY") or key in ( - "BWS_ACCESS_TOKEN", "ANTHROPIC_API_KEY", - "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", - ): - monkeypatch.delenv(key, raising=False) - return home - - -def _args(**overrides): - ns = argparse.Namespace( - force=False, - tunnel_port=None, - from_bitwarden=False, - no_bitwarden=False, - rotate_tokens=False, - restart=None, - show_tokens=False, - ) - for k, v in overrides.items(): - setattr(ns, k, v) - return ns - - -# --------------------------------------------------------------------------- -# cmd_install -# --------------------------------------------------------------------------- - - -def test_cmd_install_success_returns_0(hermes_home, monkeypatch): - monkeypatch.setattr(ip, "install_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "v0.39.0-test") - rc = proxy_cli.cmd_install(_args()) - assert rc == 0 - - -def test_cmd_install_failure_returns_1(hermes_home, monkeypatch): - def boom(**kw): - raise RuntimeError("download failed") - monkeypatch.setattr(ip, "install_iron_proxy", boom) - rc = proxy_cli.cmd_install(_args()) - assert rc == 1 - - -# --------------------------------------------------------------------------- -# cmd_setup — --from-bitwarden fail-loud paths -# --------------------------------------------------------------------------- - - -def test_cmd_setup_from_bitwarden_refuses_when_bw_disabled(hermes_home, monkeypatch): - """When --from-bitwarden is passed but secrets.bitwarden.enabled=false, - the wizard must FAIL rather than silently rewriting credential_source - to bitwarden.""" - - from hermes_cli.config import load_config, save_config - - cfg = load_config() - cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} - save_config(cfg) - - # Pre-stub install + CA so we get to step 3. - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") - monkeypatch.setattr( - ip, "ensure_ca_cert", - lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), - ) - - rc = proxy_cli.cmd_setup(_args(from_bitwarden=True)) - assert rc == 1 - # Verify we did NOT write credential_source: bitwarden to config. - cfg2 = load_config() - proxy_cfg = cfg2.get("proxy") or {} - assert proxy_cfg.get("credential_source", "env") != "bitwarden" - - -def test_cmd_setup_from_bitwarden_refuses_when_token_missing(hermes_home, monkeypatch): - """--from-bitwarden with secrets.bitwarden.enabled=true but BWS access - token unset → fail loud, not silent env-fallback.""" - - from hermes_cli.config import load_config, save_config - - cfg = load_config() - cfg.setdefault("secrets", {})["bitwarden"] = { - "enabled": True, - "project_id": "test-proj", - "access_token_env": "BWS_ACCESS_TOKEN", - } - save_config(cfg) - monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False) - - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") - monkeypatch.setattr( - ip, "ensure_ca_cert", - lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), - ) - - rc = proxy_cli.cmd_setup(_args(from_bitwarden=True)) - assert rc == 1 - - -def test_cmd_setup_from_bitwarden_refuses_on_empty_vault(hermes_home, monkeypatch): - """If BW returns {} (empty vault / scoped wrong / unreachable), fail - loud rather than silently writing credential_source: bitwarden.""" - - from hermes_cli.config import load_config, save_config - - cfg = load_config() - cfg.setdefault("secrets", {})["bitwarden"] = { - "enabled": True, - "project_id": "test-proj", - "access_token_env": "BWS_ACCESS_TOKEN", - } - save_config(cfg) - monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-token") - - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") - monkeypatch.setattr( - ip, "ensure_ca_cert", - lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), - ) - - # Mock fetch_bitwarden_secrets to return an empty dict (empty vault). - fake_bw = MagicMock() - fake_bw.fetch_bitwarden_secrets = lambda **kw: ({}, []) - monkeypatch.setattr("agent.secret_sources.bitwarden", fake_bw, raising=False) - import sys - sys.modules["agent.secret_sources.bitwarden"] = fake_bw - - rc = proxy_cli.cmd_setup(_args(from_bitwarden=True)) - assert rc == 1 - - -def test_cmd_setup_rejects_tunnel_port_zero(hermes_home, monkeypatch): - """--tunnel-port=0 is rejected explicitly (was silently substituting - the default before the fix).""" - - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") - monkeypatch.setattr( - ip, "ensure_ca_cert", - lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), - ) - rc = proxy_cli.cmd_setup(_args(tunnel_port=0)) - assert rc == 1 - - -@pytest.mark.parametrize("bad_port", [-1, 65535, 65536]) -def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, bad_port): - """The egress wizard owns the derived HTTP listener at tunnel_port+1, - so both listener ports must fit in the TCP range.""" - - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") - monkeypatch.setattr( - ip, - "ensure_ca_cert", - lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), - ) - - rc = proxy_cli.cmd_setup(_args(tunnel_port=bad_port)) - assert rc == 1 - - -# --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- - - -def test_cmd_start_refuses_when_proxy_disabled(hermes_home, monkeypatch): - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = False - save_config(cfg) - - 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 - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["auto_install"] = False - save_config(cfg) - - captured: dict = {} - - def fake_start_proxy(**kw): - captured.update(kw) - s = ip.ProxyStatus(pid=4242, listening=True, tunnel_port=9090) - return s - - monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) - monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - - rc = proxy_cli.cmd_start(_args()) - assert rc == 0 - assert captured.get("install_if_missing") is False - - -def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwarden( - hermes_home, monkeypatch, -): - """When credential_source=bitwarden, cmd_start must wire - refresh_secrets_from_bitwarden=True into start_proxy. That's what - delivers the rotation promise the docs make.""" - - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["credential_source"] = "bitwarden" - cfg.setdefault("secrets", {})["bitwarden"] = { - "enabled": True, - "project_id": "test-proj-id", - "access_token_env": "BWS_ACCESS_TOKEN", - } - save_config(cfg) - # v3: cmd_start now pre-checks BWS access token + project_id before - # calling start_proxy. Provide both so we get to the rotation - # wire-up code path. - monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-access-token") - - captured: dict = {} - def fake_start_proxy(**kw): - captured.update(kw) - s = ip.ProxyStatus() - s.pid = 4242 - s.listening = True - s.tunnel_port = 9090 - return s - monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) - monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - - rc = proxy_cli.cmd_start(_args()) - assert rc == 0 - assert captured.get("refresh_secrets_from_bitwarden") is True - assert captured.get("bitwarden_config") is not None - - -def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch): - """stephenschoettler #1: when credential_source=bitwarden but the - access-token env var is empty, cmd_start must fail-loud BEFORE - start_proxy can silently fall back to parent env.""" - - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["credential_source"] = "bitwarden" - cfg.setdefault("secrets", {})["bitwarden"] = { - "enabled": True, - "project_id": "test-proj-id", - "access_token_env": "BWS_ACCESS_TOKEN", - } - save_config(cfg) - monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False) - - # Sentinel: start_proxy must NOT be called. - def must_not_call(**kw): - 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: []) - - rc = proxy_cli.cmd_start(_args()) - assert rc == 1 - - -def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env( - hermes_home, monkeypatch, -): - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["credential_source"] = "env" - save_config(cfg) - - captured: dict = {} - def fake_start_proxy(**kw): - captured.update(kw) - s = ip.ProxyStatus() - s.pid = 4242 - s.listening = True - return s - monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) - monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - - rc = proxy_cli.cmd_start(_args()) - assert rc == 0 - assert captured.get("refresh_secrets_from_bitwarden") is False - - -# --------------------------------------------------------------------------- -# cmd_stop, cmd_status, cmd_disable, cmd_config -# --------------------------------------------------------------------------- - - -def test_cmd_stop_returns_0_when_running(hermes_home, monkeypatch): - monkeypatch.setattr(ip, "stop_proxy", lambda: True) - rc = proxy_cli.cmd_stop(_args()) - assert rc == 0 - - -def test_cmd_stop_returns_0_when_already_stopped(hermes_home, monkeypatch): - monkeypatch.setattr(ip, "stop_proxy", lambda: False) - rc = proxy_cli.cmd_stop(_args()) - assert rc == 0 - - -# --------------------------------------------------------------------------- -# cmd_restart -# --------------------------------------------------------------------------- - - -def test_cmd_restart_stops_then_starts(hermes_home, monkeypatch): - calls = [] - monkeypatch.setattr(ip, "stop_proxy", lambda: (calls.append("stop"), True)[1]) - monkeypatch.setattr( - proxy_cli, "cmd_start", - lambda args: (calls.append("start"), 0)[1], - ) - rc = proxy_cli.cmd_restart(_args()) - assert rc == 0 - # stop must precede start, and both must run - assert calls == ["stop", "start"] - - -def test_cmd_restart_starts_even_when_not_previously_running(hermes_home, monkeypatch): - calls = [] - monkeypatch.setattr(ip, "stop_proxy", lambda: (calls.append("stop"), False)[1]) - monkeypatch.setattr( - proxy_cli, "cmd_start", - lambda args: (calls.append("start"), 0)[1], - ) - rc = proxy_cli.cmd_restart(_args()) - assert rc == 0 - assert calls == ["stop", "start"] - - -def test_cmd_restart_propagates_start_failure(hermes_home, monkeypatch): - monkeypatch.setattr(ip, "stop_proxy", lambda: True) - monkeypatch.setattr(proxy_cli, "cmd_start", lambda args: 1) - rc = proxy_cli.cmd_restart(_args()) - assert rc == 1 - - -# --------------------------------------------------------------------------- -# _load_env_file_into_environ — setup discovers keys kept only in ~/.hermes/.env -# --------------------------------------------------------------------------- - - -def test_load_env_file_backfills_provider_keys(hermes_home, monkeypatch): - # Key present in .env but NOT exported in the process env. - monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_env", - lambda: {"OPENROUTER_API_KEY": "sk-or-from-dotenv", "UNRELATED": "x"}, - ) - added = proxy_cli._load_env_file_into_environ() - assert added >= 1 - assert os.environ.get("OPENROUTER_API_KEY") == "sk-or-from-dotenv" - # Only known provider names are backfilled, not arbitrary secrets. - assert "UNRELATED" not in os.environ - - -def test_load_env_file_does_not_override_exported_value(hermes_home, monkeypatch): - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-exported-wins") - monkeypatch.setattr( - "hermes_cli.config.load_env", - lambda: {"OPENROUTER_API_KEY": "sk-or-from-dotenv"}, - ) - proxy_cli._load_env_file_into_environ() - # An exported value always wins over the .env file. - assert os.environ["OPENROUTER_API_KEY"] == "sk-or-exported-wins" - - -def test_cmd_status_returns_0(hermes_home, monkeypatch): - monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus()) - monkeypatch.setattr(ip, "load_mappings", lambda: []) - monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - rc = proxy_cli.cmd_status(_args()) - assert rc == 0 - - -def test_cmd_disable_uses_public_status_pid_not_private_read_pid( - hermes_home, monkeypatch, -): - """cmd_disable must read status.pid (which incorporates the _pid_alive - check) — NOT ip._read_pid() directly (which would fire a spurious - 'still running' warning for a stale pidfile from a crashed run).""" - - from hermes_cli.config import load_config, save_config - - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - save_config(cfg) - - # Pidfile exists but the process is dead. Old code would have warned - # "still running"; the new code reads status.pid which returns None - # because _pid_alive is False, so no spurious warning. - state = ip._proxy_state_dir() - (state / "iron-proxy.pid").write_text("99999") - # _pid_alive returns False → status.pid is None. - monkeypatch.setattr(ip, "_pid_alive", lambda pid: False) - - # If cmd_disable reads _read_pid() directly (old path), this test - # would still pass — but reading status.pid is the correct - # API. Sentinel: confirm _read_pid is NOT called from cmd_disable. - read_pid_calls = [] - real_read_pid = ip._read_pid - def tracked_read_pid(*a, **kw): - read_pid_calls.append((a, kw)) - return real_read_pid(*a, **kw) - monkeypatch.setattr(ip, "_read_pid", tracked_read_pid) - - rc = proxy_cli.cmd_disable(_args()) - assert rc == 0 - # cmd_disable should call get_status() (which may internally call - # _read_pid), but should NOT call _read_pid from its own body. - # Hard to assert directly without source-introspection — the meatier - # assertion is that no "still running" message fired with a stale - # pidfile. That's covered by inspecting return code + config - # mutation only. - from hermes_cli.config import load_config as _lc - cfg2 = _lc() - assert cfg2["proxy"]["enabled"] is False - - -def test_cmd_config_returns_0_when_present(hermes_home, monkeypatch): - fake = ip.ProxyStatus() - fake.config_path = hermes_home / "proxy.yaml" - monkeypatch.setattr(ip, "get_status", lambda: fake) - rc = proxy_cli.cmd_config(_args()) - assert rc == 0 - - -def test_cmd_config_returns_1_when_missing(hermes_home, monkeypatch): - monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus()) - rc = proxy_cli.cmd_config(_args()) - assert rc == 1 - - -# --------------------------------------------------------------------------- -# Argparse wiring — dest='egress_command' regression -# --------------------------------------------------------------------------- - - -def test_register_cli_uses_egress_command_dest(): - """The subparser dest must be 'egress_command' to stay disjoint from - the inbound OAuth 'hermes proxy' subparser (dest='proxy_command'). - A future grep-and-refactor on proxy_command should not hit this - subparser by accident.""" - - parser = argparse.ArgumentParser(prog="hermes egress") - proxy_cli.register_cli(parser) - # Parse a no-op invocation and confirm the attribute name. - args = parser.parse_args(["install"]) - assert hasattr(args, "egress_command") - assert not hasattr(args, "proxy_command") - - -def test_egress_subcommands_registered(): - """Smoke test: every documented subcommand parses without error.""" - - parser = argparse.ArgumentParser(prog="hermes egress") - proxy_cli.register_cli(parser) - for sub in ("install", "setup", "start", "stop", "status", "disable", "config"): - args = parser.parse_args([sub]) - assert args.egress_command == sub - - -def test_setup_has_rotate_tokens_flag(): - """--rotate-tokens is the documented escape hatch for re-rolling - every proxy token (used after a suspected token leak). Default is - preserve-existing.""" - - parser = argparse.ArgumentParser(prog="hermes egress") - proxy_cli.register_cli(parser) - args = parser.parse_args(["setup"]) - assert args.rotate_tokens is False - args = parser.parse_args(["setup", "--rotate-tokens"]) - assert args.rotate_tokens is True - - -# --------------------------------------------------------------------------- -# v4 round: credential_source=bitwarden with secrets.bitwarden disabled -# must NOT silently degrade to host-env secrets -# --------------------------------------------------------------------------- - - -def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeypatch): - """config keeps credential_source: bitwarden but secrets.bitwarden.enabled - later flips to false — cmd_start must refuse, not silently start on - host env (the silent-degrade class strict mode is meant to close).""" - - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["credential_source"] = "bitwarden" - cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} - save_config(cfg) - - def must_not_call(**kw): - 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: []) - - rc = proxy_cli.cmd_start(_args()) - assert rc == 1 - - -def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback( - hermes_home, monkeypatch, -): - """Same scenario but proxy.allow_env_fallback=true is the documented - escape hatch — start proceeds (with a warning).""" - - from hermes_cli.config import load_config, save_config - cfg = load_config() - cfg.setdefault("proxy", {})["enabled"] = True - cfg["proxy"]["credential_source"] = "bitwarden" - cfg["proxy"]["allow_env_fallback"] = True - cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} - save_config(cfg) - - captured: dict = {} - - def fake_start_proxy(**kw): - captured.update(kw) - s = ip.ProxyStatus() - s.pid = 4242 - s.listening = True - return s - - monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) - monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - - rc = proxy_cli.cmd_start(_args()) - assert rc == 0 - # BW refresh is off (bitwarden disabled), running on env secrets. - assert captured.get("refresh_secrets_from_bitwarden") is False - - -def test_cmd_setup_audit_log_failure_is_warning_not_abort(hermes_home, monkeypatch): - """On the pinned v0.39 the daemon never writes audit.log, so a - pre-create failure must not abort the wizard.""" - - monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") - monkeypatch.setattr(ip, "discover_provider_mappings", lambda **kw: [ - ip.TokenMapping( - proxy_token="hermes-proxy-deadbeef", - real_env_name="OPENROUTER_API_KEY", - upstream_hosts=("openrouter.ai",), - ), - ]) - monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) - - def boom(path): - raise RuntimeError("could not pre-create audit log (synthetic)") - monkeypatch.setattr(ip, "ensure_audit_log", boom) - - rc = proxy_cli.cmd_setup(_args()) - assert rc == 0 diff --git a/tests/test_iron_proxy_e2e.py b/tests/test_iron_proxy_e2e.py deleted file mode 100644 index 37c3936c0da..00000000000 --- a/tests/test_iron_proxy_e2e.py +++ /dev/null @@ -1,368 +0,0 @@ -"""End-to-end smoke test for the iron-proxy egress integration. - -Spins up the REAL iron-proxy binary (auto-installed if not present), routes -a curl request through it against a local fake upstream, and verifies that -the Authorization header was swapped from a proxy token to a real secret. - -Gated on the network. Skipped by default in CI unless the user explicitly -opts in with --run-e2e or HERMES_RUN_E2E=1. This is intentional — the test -downloads ~16MB and requires both `openssl` and `curl` to be present. -""" - -from __future__ import annotations - -import os -import socket -import subprocess -import threading -import time -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path -from typing import Optional - -import pytest - -from agent.proxy_sources import iron_proxy as ip - - -pytestmark = pytest.mark.skipif( - os.environ.get("HERMES_RUN_E2E", "0") != "1", - reason="E2E proxy test — set HERMES_RUN_E2E=1 to run (requires network + curl + openssl)", -) - - -@pytest.fixture -def hermes_home(tmp_path, monkeypatch): - home = tmp_path / "hermes" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - return home - - -def _free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -class _CaptureHandler(BaseHTTPRequestHandler): - """Records the Authorization header of every incoming request.""" - - captured_auth: Optional[str] = None # class-level so tests can read it - - def do_GET(self): - type(self).captured_auth = self.headers.get("Authorization") - 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 # silence access log - - -def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypatch): - """Real binary, real CA, real curl. Verify the proxy swaps a proxy-token - Authorization header for the real bearer value before forwarding.""" - - if not __import__("shutil").which("curl"): - pytest.skip("curl not available") - if not __import__("shutil").which("openssl"): - pytest.skip("openssl not available") - - # ----- fake upstream ---------------------------------------------------- - upstream_port = _free_port() - server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler) - server_thread = threading.Thread(target=server.serve_forever, daemon=True) - server_thread.start() - - try: - # ----- iron-proxy install + CA + config --------------------------- - binary = ip.install_iron_proxy() - assert binary.exists() - ca_crt, ca_key = ip.ensure_ca_cert() - assert ca_crt.exists() - - real_secret = "sk-real-upstream-value-deadbeef" - monkeypatch.setenv("TEST_UPSTREAM_KEY", real_secret) - proxy_token = ip.mint_proxy_token("test") - - mapping = ip.TokenMapping( - proxy_token=proxy_token, - real_env_name="TEST_UPSTREAM_KEY", - upstream_hosts=("127.0.0.1",), - ) - - 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"], - # Test target is on loopback — clear the default IMDS+loopback - # deny list so iron-proxy will dial 127.0.0.1. - upstream_deny_cidrs=[], - # Hermetic: pin the bind to loopback. Without this, Linux - # hosts with docker0 present would bind the bridge gateway - # (the production default) and the loopback curl below would - # never reach the proxy. - http_listen=[f"127.0.0.1:{tunnel_port}"], - ) - ip.write_proxy_config(cfg) - ip.write_mappings([mapping]) - - # ----- start the proxy -------------------------------------------- - 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 - - # Wait up to 10s for the listener to come up. - 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") - - # ----- request through the proxy ---------------------------------- - # The fake upstream listens on plain HTTP (not HTTPS). Plain-HTTP - # absolute-form forwards are served by the http_listen listener on - # tunnel_port + 1 (tunnel_port itself is the CONNECT/MITM listener - # that HTTPS_PROXY traffic hits). The secrets transform fires on - # the plain forward too, swapping the Authorization header. - result = subprocess.run( - [ - "curl", - "--silent", - "--max-time", "10", - "-x", f"http://127.0.0.1:{tunnel_port + 1}", - "-H", f"Authorization: Bearer {proxy_token}", - f"http://127.0.0.1:{upstream_port}/", - ], - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"curl failed: {result.stderr}" - # Some iron-proxy versions return 200 with no body; only the swap matters. - captured = _CaptureHandler.captured_auth - assert captured is not None, "upstream never received the request" - assert real_secret in captured, ( - f"Authorization header was not swapped — upstream saw: {captured!r}" - ) - assert proxy_token not in captured, ( - f"Proxy token leaked through to upstream: {captured!r}" - ) - - finally: - # ----- cleanup ------------------------------------------------------ - try: - ip.stop_proxy() - except Exception: - pass - server.shutdown() - server.server_close() - - -class _CaptureXApiKeyHandler(BaseHTTPRequestHandler): - """Records the x-api-key header of every incoming request.""" - - captured_key: Optional[str] = None - - def do_GET(self): - type(self).captured_key = self.headers.get("x-api-key") - body = b'{"ok": true}' - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args, **kwargs): - return - - -def test_iron_proxy_swaps_x_api_key_header_end_to_end(hermes_home, monkeypatch): - """Header-auth providers: the secrets transform must swap the proxy - token out of a NON-Authorization header (x-api-key — the Anthropic - native scheme) on the pinned binary.""" - - if not __import__("shutil").which("curl"): - pytest.skip("curl not available") - if not __import__("shutil").which("openssl"): - pytest.skip("openssl not available") - - upstream_port = _free_port() - server = HTTPServer(("127.0.0.1", upstream_port), _CaptureXApiKeyHandler) - threading.Thread(target=server.serve_forever, daemon=True).start() - - try: - binary = ip.install_iron_proxy() - assert binary.exists() - ca_crt, ca_key = ip.ensure_ca_cert() - - real_secret = "sk-ant-real-value-cafebabe" - monkeypatch.setenv("TEST_XAPI_KEY", real_secret) - proxy_token = ip.mint_proxy_token("anthropic") - - mapping = ip.TokenMapping( - proxy_token=proxy_token, - real_env_name="TEST_XAPI_KEY", - upstream_hosts=("127.0.0.1",), - match_headers=("x-api-key", "Authorization"), - ) - - tunnel_port = _free_port() - cfg = ip.build_proxy_config( - mappings=[mapping], - ca_cert=ca_crt, - ca_key=ca_key, - tunnel_port=tunnel_port, - allowed_hosts=["127.0.0.1"], - upstream_deny_cidrs=[], - http_listen=[f"127.0.0.1:{tunnel_port}"], - ) - ip.write_proxy_config(cfg) - ip.write_mappings([mapping]) - - try: - status = ip.start_proxy() - except RuntimeError as exc: - pytest.skip(f"iron-proxy could not start in this environment: {exc}") - assert status.pid is not None - - for _ in range(50): - if ip._port_listening("127.0.0.1", tunnel_port): - break - time.sleep(0.2) - else: - pytest.fail("iron-proxy never started listening on the tunnel port") - - result = subprocess.run( - [ - "curl", - "--silent", - "--max-time", "10", - "-x", f"http://127.0.0.1:{tunnel_port + 1}", - "-H", f"x-api-key: {proxy_token}", - f"http://127.0.0.1:{upstream_port}/", - ], - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"curl failed: {result.stderr}" - captured = _CaptureXApiKeyHandler.captured_key - assert captured is not None, "upstream never received the request" - assert real_secret in captured, ( - f"x-api-key header was not swapped — upstream saw: {captured!r}" - ) - assert proxy_token not in captured, ( - f"Proxy token leaked through to upstream: {captured!r}" - ) - - finally: - try: - ip.stop_proxy() - except Exception: - pass - server.shutdown() - server.server_close() - - -def test_iron_proxy_management_reload_end_to_end(hermes_home, monkeypatch): - """Real binary: the management listener comes up, an authenticated - POST /v1/reload succeeds after a config edit, and the edited ruleset - takes effect WITHOUT a restart (same pid).""" - - if not __import__("shutil").which("curl"): - pytest.skip("curl not available") - if not __import__("shutil").which("openssl"): - pytest.skip("openssl not available") - - upstream_port = _free_port() - server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler) - threading.Thread(target=server.serve_forever, daemon=True).start() - - try: - binary = ip.install_iron_proxy() - assert binary.exists() - ca_crt, ca_key = ip.ensure_ca_cert() - - real_secret = "sk-real-reload-value-0badf00d" - monkeypatch.setenv("TEST_RELOAD_KEY", real_secret) - token_v1 = ip.mint_proxy_token("v1") - - def _write_cfg(mapping): - cfg = ip.build_proxy_config( - mappings=[mapping], - ca_cert=ca_crt, - ca_key=ca_key, - tunnel_port=tunnel_port, - allowed_hosts=["127.0.0.1"], - upstream_deny_cidrs=[], - http_listen=[f"127.0.0.1:{tunnel_port}"], - ) - ip.write_proxy_config(cfg) - ip.write_mappings([mapping]) - - tunnel_port = _free_port() - _write_cfg(ip.TokenMapping( - proxy_token=token_v1, - real_env_name="TEST_RELOAD_KEY", - upstream_hosts=("127.0.0.1",), - )) - - try: - status = ip.start_proxy() - except RuntimeError as exc: - pytest.skip(f"iron-proxy could not start in this environment: {exc}") - pid_before = status.pid - assert pid_before is not None - - for _ in range(50): - if ip._port_listening("127.0.0.1", tunnel_port): - break - time.sleep(0.2) - else: - pytest.fail("iron-proxy never started listening") - - # Rotate the sandbox-visible token in the config, then hot-reload. - token_v2 = ip.mint_proxy_token("v2") - _write_cfg(ip.TokenMapping( - proxy_token=token_v2, - real_env_name="TEST_RELOAD_KEY", - upstream_hosts=("127.0.0.1",), - )) - assert ip.reload_proxy() is True - - # Same daemon (no restart) ... - assert ip.get_status().pid == pid_before - - # ... but the NEW token now swaps. - _CaptureHandler.captured_auth = None - result = subprocess.run( - [ - "curl", "--silent", "--max-time", "10", - "-x", f"http://127.0.0.1:{tunnel_port + 1}", - "-H", f"Authorization: Bearer {token_v2}", - f"http://127.0.0.1:{upstream_port}/", - ], - capture_output=True, text=True, - ) - assert result.returncode == 0, f"curl failed: {result.stderr}" - captured = _CaptureHandler.captured_auth - assert captured is not None and real_secret in captured, ( - f"post-reload token was not swapped — upstream saw: {captured!r}" - ) - - finally: - try: - ip.stop_proxy() - except Exception: - pass - server.shutdown() - server.server_close() diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 0dcc04caf40..c85402a3c2d 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -45,13 +45,11 @@ def _make_dummy_env(**kwargs): persistent_filesystem=kwargs.get("persistent_filesystem", False), task_id=kwargs.get("task_id", "test-task"), volumes=kwargs.get("volumes", []), - forward_env=kwargs.get("forward_env"), network=kwargs.get("network", True), host_cwd=kwargs.get("host_cwd"), auto_mount_cwd=kwargs.get("auto_mount_cwd", False), env=kwargs.get("env"), run_as_host_user=kwargs.get("run_as_host_user", False), - extra_args=kwargs.get("extra_args", []), persist_across_processes=kwargs.get("persist_across_processes", True), ) @@ -364,52 +362,6 @@ def test_docker_env_appears_in_run_command(monkeypatch): assert "GNUPGHOME=/root/.gnupg" in run_args_str -def _node_options_from_run(calls): - run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] - assert run_calls, "docker run should have been called" - args = run_calls[0][0] - for i, a in enumerate(args): - if a == "-e" and i + 1 < len(args) and args[i + 1].startswith("NODE_OPTIONS="): - return args[i + 1].split("=", 1)[1] - return None - - -def test_egress_node_options_overrides_conflicting_ca_flag(monkeypatch): - """maxpetrusenko P1: a conflicting docker_env NODE_OPTIONS CA-mode flag - (--use-bundled-ca) must be replaced by the egress-required --use-openssl-ca, - not left to survive alongside it (final Node trust would depend on order).""" - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setattr( - docker_env, "_egress_proxy_args_for_docker", - lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []), - ) - calls = _mock_subprocess_run(monkeypatch) - - _make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=8192 --use-bundled-ca"}) - - node_opts = (_node_options_from_run(calls) or "").split() - assert "--use-openssl-ca" in node_opts, "egress CA flag must be present" - assert "--use-bundled-ca" not in node_opts, "conflicting CA flag must be stripped" - # Operator's unrelated tuning must be preserved. - assert "--max-old-space-size=8192" in node_opts - - -def test_egress_node_options_preserves_operator_tuning(monkeypatch): - """Non-conflicting operator NODE_OPTIONS survive the egress append-merge.""" - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setattr( - docker_env, "_egress_proxy_args_for_docker", - lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []), - ) - calls = _mock_subprocess_run(monkeypatch) - - _make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=4096"}) - - node_opts = (_node_options_from_run(calls) or "").split() - assert "--use-openssl-ca" in node_opts - assert "--max-old-space-size=4096" in node_opts - - def test_docker_env_appears_in_init_env_args(monkeypatch): """Explicit docker_env values should appear in _build_init_env_args.""" env = _make_execute_only_env() @@ -727,7 +679,6 @@ def test_labels_attribute_populated_after_init(monkeypatch): "hermes-agent": "1", "hermes-task-id": "abc", "hermes-profile": "default", - "hermes-egress": "off", } @@ -759,13 +710,8 @@ def _mock_subprocess_run_with_reuse(monkeypatch, ps_state: str | None, if sub == "ps": if ps_state is None: return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - # 3-field format: ID, State, EgressLabel. When egress_label - # is "off" the code parses all three fields; means - # the container has no egress label, which is acceptable. return subprocess.CompletedProcess( - cmd, 0, - stdout=f"reused-cid\t{ps_state}\t\n", - stderr="", + cmd, 0, stdout=f"reused-cid\t{ps_state}\n", stderr="", ) if sub == "start": if not start_succeeds: @@ -808,95 +754,6 @@ def test_reuse_attaches_to_running_container_without_docker_run(monkeypatch): ) -def test_egress_enabled_does_not_reuse_pre_egress_container(monkeypatch): - """A container created before egress was enabled lacks the proxy env vars - and CA mount. Reusing it would silently bypass the credential firewall.""" - - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") - monkeypatch.setattr( - docker_env, - "_egress_proxy_args_for_docker", - lambda: ( - ["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"], - {"HTTPS_PROXY": "http://host.docker.internal:9090"}, - ["--add-host", "host.docker.internal:host-gateway"], - ), - ) - calls = [] - - def _run(cmd, **kwargs): - calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs)) - if isinstance(cmd, list) and len(cmd) >= 2: - sub = cmd[1] - if sub == "version": - return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") - if sub == "ps": - # Simulate an old pre-egress container: without the egress label - # filter it would match; with the filter Docker returns no match. - assert any(str(part).startswith("label=hermes-egress=") for part in cmd) - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if sub == "run": - return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - monkeypatch.setattr(docker_env.subprocess, "run", _run) - - env = _make_dummy_env(task_id="reuse-egress") - - assert env._container_id == "fresh-cid" - run_invocations = [ - c for c in calls - if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run" - ] - assert run_invocations, "egress-enabled containers require a fresh docker run" - - -def test_forward_env_provider_key_collision_refuses_under_egress(monkeypatch): - """docker_forward_env is explicit, but it still must not smuggle real - provider keys into an enforced egress sandbox.""" - - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-real") - monkeypatch.setattr( - docker_env, - "_egress_proxy_args_for_docker", - lambda: ( - [], - { - "HTTPS_PROXY": "http://host.docker.internal:9090", - "OPENROUTER_API_KEY": "hermes-proxy-openrouter-token", - "HERMES_PROXY_TOKEN_OPENROUTER_API_KEY": "hermes-proxy-openrouter-token", - }, - [], - ), - ) - _mock_subprocess_run(monkeypatch) - - with pytest.raises(RuntimeError, match="docker_forward_env.*OPENROUTER_API_KEY"): - _make_dummy_env(forward_env=["OPENROUTER_API_KEY"]) - - -def test_extra_args_proxy_override_refuses_under_egress(monkeypatch): - """docker_extra_args are appended after Hermes args, so egress enforcement - must reject critical overrides before Docker sees them.""" - - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setattr( - docker_env, - "_egress_proxy_args_for_docker", - lambda: ( - [], - {"HTTPS_PROXY": "http://host.docker.internal:9090"}, - [], - ), - ) - _mock_subprocess_run(monkeypatch) - - with pytest.raises(RuntimeError, match="docker_extra_args.*HTTPS_PROXY"): - _make_dummy_env(extra_args=["-e", "HTTPS_PROXY="]) - - def test_reuse_starts_stopped_container_before_attaching(monkeypatch): """A labeled container in ``exited`` state must be restarted via ``docker start`` before the new Hermes process uses it. Without this @@ -1051,11 +908,10 @@ def test_find_reusable_container_prefers_running_over_stopped(monkeypatch): if cmd[1] == "version": return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") if cmd[1] == "ps": - # Two matches: stopped first, running second. 3-field format - # with absent egress label for the "off" path. + # Two matches: stopped first, running second. return subprocess.CompletedProcess( cmd, 0, - stdout="stopped-cid\texited\t\nrunning-cid\trunning\t\n", + stdout="stopped-cid\texited\nrunning-cid\trunning\n", stderr="", ) return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") @@ -1068,71 +924,6 @@ def test_find_reusable_container_prefers_running_over_stopped(monkeypatch): ) -def test_find_reusable_handles_empty_label_string(monkeypatch): - """Docker CLI v29.5.3 returns an empty string (NOT ````) - for absent labels. The trailing tab produces ``cid\\trunning\\t\\n``; - we must not strip the trailing tab or the three-field parser drops the - container. Regression test for the egilewski review on #48073.""" - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") - - def _run(cmd, **kwargs): - if isinstance(cmd, list) and len(cmd) >= 2: - if cmd[1] == "version": - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") - if cmd[1] == "ps": - # Docker v29.5.3: absent label → empty string, trailing tab - return subprocess.CompletedProcess( - cmd, 0, - stdout="safe-cid\trunning\t\n", - stderr="", - ) - return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") - - monkeypatch.setattr(docker_env.subprocess, "run", _run) - - env = _make_dummy_env(task_id="empty-label") - assert env._container_id == "safe-cid", ( - f"container with empty-string label should be reused, got {env._container_id!r}" - ) - - -def test_reuse_off_rejects_non_off_egress_container(monkeypatch): - """When egress is off, a container that still has hermes-egress=on - (e.g. from before ``hermes egress disable``) must be rejected and a - fresh container created. The post-filter protects against silently - reusing a container with baked-in proxy env and CA mounts.""" - - monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") - monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") - - def _run(cmd, **kwargs): - if isinstance(cmd, list) and len(cmd) >= 2: - if cmd[1] == "version": - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") - if cmd[1] == "ps": - # Return a container with hermes-egress=on. With egress=off - # the three-field format includes the label; the post-filter - # must skip this entry. - return subprocess.CompletedProcess( - cmd, 0, - stdout="stale-cid\trunning\ton\n", - stderr="", - ) - if cmd[1] == "run": - return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - monkeypatch.setattr(docker_env.subprocess, "run", _run) - - env = _make_dummy_env(task_id="egress-off-reject") - # Should fall through to fresh container because the stale one has - # hermes-egress=on. - assert env._container_id == "fresh-cid", ( - f"expected fresh container, got {env._container_id!r}" - ) - - # ── Cleanup correctness (issue #20561) ──────────────────────────── diff --git a/tools/environments/docker.py b/tools/environments/docker.py index b5a5d441f85..74f9fa82c8b 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -5,7 +5,6 @@ configurable resource limits (CPU, memory, disk), and optional filesystem persistence via bind mounts. """ -import hashlib import json import logging import os @@ -37,7 +36,6 @@ _DOCKER_SEARCH_PATHS = [ _docker_executable: Optional[str] = None # resolved once, cached _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -_EGRESS_LABEL_KEY = "hermes-egress" def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]: @@ -366,247 +364,6 @@ _PRIVDROP_CAP_ARGS = [ ] -def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str]]: - """Build the docker mount/env/host args needed to route a sandbox through - the iron-proxy egress firewall. - - Returns ``(volume_args, env_overrides, host_args)``: - - * ``volume_args`` — read-only bind mount of the CA cert into the container - (extends docker's ``-v`` argv list) - * ``env_overrides`` — env vars to set on container creation: ``HTTPS_PROXY``, - ``HTTP_PROXY``, ``NO_PROXY`` (loopback only), Python/Node/curl CA-bundle - paths, and one ``HERMES_PROXY_TOKEN_`` per minted mapping - * ``host_args`` — extra ``--add-host`` flags so the container can reach the - host-side proxy (Linux needs ``host.docker.internal:host-gateway``; - Docker Desktop populates this automatically on macOS/Windows) - - Returns three empty containers when the proxy is disabled, not yet set up, - or not currently running. If ``proxy.enforce_on_docker`` is true and the - proxy is enabled-but-not-running, raises ``RuntimeError`` so the docker - backend refuses to start the sandbox. - """ - - # Narrow except: ImportError is the only legitimate failure here. - # Bare ``except Exception`` would hide AttributeError, SyntaxError in - # the config module, etc. and silently start the sandbox without - # proxy enforcement. We let unexpected exceptions propagate so the - # docker backend visibly fails rather than degrading silently. - try: - from hermes_cli.config import load_config - from agent.proxy_sources import iron_proxy as ip - except ImportError as exc: - logger.debug("Egress proxy plumbing unavailable: %s", exc) - return ([], {}, []) - - cfg = load_config() - proxy_cfg = cfg.get("proxy") or {} - if not proxy_cfg.get("enabled"): - return ([], {}, []) - - status = ip.get_status() - enforce = bool(proxy_cfg.get("enforce_on_docker", True)) - - if not status.configured: - msg = ( - "proxy.enabled is true but iron-proxy is not configured. " - "Run `hermes egress setup` to mint tokens and write proxy.yaml." - ) - if enforce: - raise RuntimeError(msg) - logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) - return ([], {}, []) - - if not (status.pid and status.listening): - msg = ( - f"iron-proxy is enabled but not running on port {status.tunnel_port}. " - "Start it with `hermes egress start`." - ) - if enforce: - raise RuntimeError(msg) - logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) - return ([], {}, []) - - if status.ca_cert_path is None or not status.ca_cert_path.exists(): - # status.configured was True a moment ago but the CA file has - # disappeared. Treat this with the same enforce semantics as the - # other failure branches — silently dropping the CA mount would - # leave the sandbox with proxy env vars pointing at iron-proxy - # but no trust anchor, so every TLS handshake would 5xx; or - # worse, with enforce_on_docker=false we'd drop both the proxy - # vars AND any other isolation, opening the sandbox. - msg = ( - f"iron-proxy CA cert vanished from {status.ca_cert_path}. " - "Re-run `hermes egress setup` to regenerate it." - ) - if enforce: - raise RuntimeError(msg) - logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) - return ([], {}, []) - - # Corrupt or empty mappings.json is a silent failure mode that's - # indistinguishable from an upstream outage from inside the sandbox - # (every request returns 403). Refuse to mount with empty mappings - # rather than ship a broken sandbox. - mappings = ip.load_mappings() - if not mappings: - msg = ( - "iron-proxy is configured but mappings.json is empty or " - "corrupt. Re-run `hermes egress setup` to mint provider " - "tokens before starting a sandbox." - ) - if enforce: - raise RuntimeError(msg) - logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) - return ([], {}, []) - - container_ca = "/etc/ssl/certs/hermes-egress-ca.crt" - volume_args = ["-v", f"{status.ca_cert_path}:{container_ca}:ro"] - - # tunnel_port serves CONNECT (HTTPS); the plain-HTTP forward listener - # is on tunnel_port + 1 (see build_proxy_config's listener-role notes). - proxy_url = f"http://host.docker.internal:{status.tunnel_port}" - plain_http_url = f"http://host.docker.internal:{status.tunnel_port + 1}" - env_overrides: dict[str, str] = { - # HTTPS_PROXY / HTTP_PROXY are respected by curl, requests, urllib, - # httpx, node fetch, go default transport, etc. Lowercase variants - # are also set because some tools only look at one casing. - "HTTPS_PROXY": proxy_url, - "https_proxy": proxy_url, - "HTTP_PROXY": plain_http_url, - "http_proxy": plain_http_url, - # Loopback-only NO_PROXY so localhost dev servers inside the sandbox - # (test fixtures, local LLMs) don't get sent through the proxy. - "NO_PROXY": "127.0.0.1,localhost,::1", - "no_proxy": "127.0.0.1,localhost,::1", - # CA bundle locations for the major language runtimes. iron-proxy - # presents a leaf cert signed by our CA on every MITM'd connection. - # - # CRITICAL ASYMMETRY: Python (REQUESTS_CA_BUNDLE / SSL_CERT_FILE) - # and curl (CURL_CA_BUNDLE) REPLACE the system CA store. - # NODE_EXTRA_CA_CERTS ADDS to it. A Node.js process that - # bypasses HTTPS_PROXY by using a raw socket would still see the - # system CA store and succeed where Python/curl fail validation. - # We additionally set NODE_OPTIONS=--use-openssl-ca to force Node - # through the OpenSSL store that SSL_CERT_FILE controls, narrowing - # the asymmetry. Not a complete fix — see the docs caveat — but - # closes the easy case. - "REQUESTS_CA_BUNDLE": container_ca, # Python `requests` - "SSL_CERT_FILE": container_ca, # Python ssl module / OpenSSL - "CURL_CA_BUNDLE": container_ca, # curl - "NODE_EXTRA_CA_CERTS": container_ca, # Node.js: adds to system store - # NOTE: NODE_OPTIONS is intentionally NOT placed in env_overrides - # here as a flat assignment. We need to APPEND --use-openssl-ca - # to whatever the user already has in NODE_OPTIONS (e.g. - # --max-old-space-size=4096), not clobber it. The append-merge - # happens in DockerEnvironment._merge_node_options below. - # For the agent inside the sandbox to identify itself as proxy-aware. - "HERMES_EGRESS_PROXY": "1", - # Sentinel that DockerEnvironment uses to do the NODE_OPTIONS - # append-merge. Stripped from the final env before docker run. - "_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca", - } - - # Surface the per-provider proxy tokens under the standard provider env - # names so existing SDKs and provider clients work unchanged inside the - # 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 - # host-gateway. On Desktop this is a no-op (harmless duplicate). - host_args: list[str] = ["--add-host", "host.docker.internal:host-gateway"] - - return (volume_args, env_overrides, host_args) - - -def _egress_reuse_fingerprint( - volume_args: list[str], - env_overrides: dict[str, str], - host_args: list[str], -) -> str: - """Stable Docker-label value for the egress posture of a container.""" - if not (volume_args or env_overrides or host_args): - return "off" - payload = json.dumps( - { - "volume_args": volume_args, - "env_overrides": env_overrides, - "host_args": host_args, - }, - sort_keys=True, - separators=(",", ":"), - ) - return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24] - - -def _egress_enforce_on_docker(default: bool = True) -> bool: - """Read proxy.enforce_on_docker with fail-safe defaulting.""" - try: - from hermes_cli.config import load_config as _load_cfg - - return bool((_load_cfg().get("proxy") or {}).get("enforce_on_docker", default)) - except (ImportError, OSError): - return default - except Exception: - return default - - -def _critical_egress_env_names(env_overrides: dict[str, str]) -> set[str]: - """Env names that would weaken or bypass enforced egress if overridden.""" - critical = { - "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", - "NO_PROXY", "no_proxy", - "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE", - "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", - } - critical.update( - key for key in env_overrides - if key.endswith("_API_KEY") or key.endswith("_TOKEN") - ) - return critical - - -def _extra_args_egress_collisions( - extra_args: list[str], critical_names: set[str], -) -> list[str]: - """Return docker_extra_args entries that can override egress controls.""" - collisions: list[str] = [] - env_flags = {"-e", "--env", "--env-file"} - network_flags = {"--network", "--net"} - i = 0 - while i < len(extra_args): - arg = extra_args[i] - nxt = extra_args[i + 1] if i + 1 < len(extra_args) else "" - if arg in env_flags: - if arg == "--env-file": - collisions.append(arg) - else: - name = nxt.split("=", 1)[0] - if name in critical_names: - collisions.append(name) - i += 2 - continue - if any(arg.startswith(f"{flag}=") for flag in env_flags): - if arg.startswith("--env-file="): - collisions.append("--env-file") - else: - name = arg.split("=", 1)[1].split("=", 1)[0] - if name in critical_names: - collisions.append(name) - elif arg in network_flags or any(arg.startswith(f"{flag}=") for flag in network_flags): - collisions.append(arg) - i += 1 - return sorted(set(collisions)) - - def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]: """Return the security/cap/tmpfs args tailored to the privilege mode. @@ -1029,195 +786,11 @@ class DockerEnvironment(BaseEnvironment): except Exception as e: logger.debug("Docker: could not load credential file mounts: %s", e) - # Egress credential-injection proxy (iron-proxy) — when configured, - # mount the CA cert into the sandbox and set HTTPS_PROXY + CA-bundle - # env vars so outbound traffic routes through the host-side proxy. - # The sandbox receives PROXY tokens instead of real API keys. - egress_volume_args, egress_env_overrides, egress_host_args = ( - _egress_proxy_args_for_docker() - ) - egress_label = _egress_reuse_fingerprint( - egress_volume_args, egress_env_overrides, egress_host_args, - ) - _enforce_egress = _egress_enforce_on_docker() - _critical_egress_names = _critical_egress_env_names(egress_env_overrides) - if egress_env_overrides: - _forward_collisions = sorted( - key for key in self._forward_env if key in _critical_egress_names - ) - if _forward_collisions: - _msg = ( - f"docker_forward_env would inject real egress-protected " - f"variables {_forward_collisions}; enforce_on_docker is " - f"{'enabled' if _enforce_egress else 'disabled'}." - ) - if _enforce_egress: - raise RuntimeError( - f"{_msg} Remove these names from docker_forward_env " - "or disable enforce_on_docker to opt out of egress isolation." - ) - logger.warning( - "%s Explicit docker_forward_env values will override egress tokens.", - _msg, - ) - volume_args.extend(egress_volume_args) - # egress env overrides are merged in further below alongside the - # other env_args computation. - # Explicit environment variables (docker_env config) — set at container # creation so they're available to all processes (including entrypoint). - # Egress proxy env vars (HTTPS_PROXY, CA-bundle paths, proxy tokens) - # are merged below. Precedence policy: - # - # - When egress enforcement is on AND the user's docker_env tries - # to override one of the proxy-control vars (HTTPS_PROXY, - # SSL_CERT_FILE, etc.), fail-loud rather than silently inverting - # the isolation. The CA mount + tokens would still ship while - # traffic leaves the sandbox direct with real credentials — - # exactly what enforce_on_docker is meant to prevent. - # - When enforcement is off, the user's docker_env wins (current - # behavior) but we log a warning naming both config sources. - # - When the user override is identical to the egress value, no-op. - if egress_env_overrides: - try: - from hermes_cli.config import load_config as _load_cfg_for_collision - _proxy_cfg = (_load_cfg_for_collision().get("proxy") or {}) - except (ImportError, OSError): - _proxy_cfg = {} - except Exception as _e: # noqa: BLE001 — narrowed below via yaml import - # yaml.YAMLError from a malformed config.yaml. We import - # lazily because PyYAML is a soft dep in some test envs. - try: - import yaml # noqa: F401 - except ImportError: - raise - logger.warning( - "Could not read proxy config for egress collision check: %s", - _e, - ) - _proxy_cfg = {} - _enforce_egress = bool(_proxy_cfg.get("enforce_on_docker", True)) - # Egress-controlling env vars that affect the proxy posture. - _critical_proxy_control = { - "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", - "NO_PROXY", "no_proxy", - "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE", - "NODE_EXTRA_CA_CERTS", - } - # stephenschoettler #2: also block docker_env from injecting - # real provider keys. `docker_env: {OPENROUTER_API_KEY: sk-real}` - # in config.yaml puts the live secret into the sandbox while - # egress is nominally enforced — defeats the entire feature. - # Pull the mapped real_env_name from each token mapping at - # call time so this stays in sync with whatever the operator - # has configured. - _critical_provider_keys: set[str] = set() - try: - from agent.proxy_sources import iron_proxy as _ip_for_mappings - _critical_provider_keys = { - m.real_env_name for m in _ip_for_mappings.load_mappings() - } - except Exception: # noqa: BLE001 — best-effort collision check - pass - _critical = _critical_proxy_control | _critical_provider_keys - _collisions = sorted( - k for k in _critical - if k in self._env - and ( - k not in egress_env_overrides - or self._env[k] != egress_env_overrides[k] - ) - # For provider keys, ANY override is a collision (the egress - # path mints proxy tokens; a real key in docker_env bypasses - # the swap regardless of whether the egress dict happens to - # carry it). - and ( - k in _critical_provider_keys - or (k in egress_env_overrides - and self._env[k] != egress_env_overrides[k]) - ) - ) - if _collisions: - _msg = ( - f"docker_env in config.yaml overrides egress-proxy " - f"variables {_collisions}; enforce_on_docker is " - f"{'enabled' if _enforce_egress else 'disabled'}." - ) - if _enforce_egress: - raise RuntimeError( - f"{_msg} Remove these keys from docker_env or " - "disable enforce_on_docker to opt out of egress " - "isolation." - ) - logger.warning( - "%s Falling back to docker_env values; sandbox traffic " - "will NOT route through the proxy.", _msg, - ) - - # When enforce_on_docker is true, egress overrides win. When - # false, docker_env wins (back-compat for users who deliberately - # opt out). In both cases the collision check above has already - # surfaced any disagreement. - try: - from hermes_cli.config import load_config as _load_cfg_for_precedence - _enforce_egress_merge = bool( - (_load_cfg_for_precedence().get("proxy") or {}) - .get("enforce_on_docker", True) - ) - except (ImportError, OSError): - _enforce_egress_merge = True - except Exception: # noqa: BLE001 — yaml.YAMLError or similar - # Malformed config.yaml; fail-safe to enforced. - _enforce_egress_merge = True - - if _enforce_egress_merge and egress_env_overrides: - merged_env = dict(self._env) - merged_env.update(egress_env_overrides) - else: - merged_env = dict(egress_env_overrides) - merged_env.update(self._env) - - # arshkumarsingh #1: NODE_OPTIONS append-merge. The egress path - # wants ``--use-openssl-ca`` so Node routes through the OpenSSL - # CA store ``SSL_CERT_FILE`` controls. But the operator's - # ``docker_env: {NODE_OPTIONS: "--max-old-space-size=8192"}`` - # MUST be preserved — replacing it would silently drop their - # tuning. We carry the egress flag in a sentinel key - # ``_HERMES_EGRESS_NODE_OPTIONS_APPEND`` and merge here. - _egress_node_append = merged_env.pop( - "_HERMES_EGRESS_NODE_OPTIONS_APPEND", None, - ) - if _egress_node_append: - existing_node = merged_env.get("NODE_OPTIONS", "") - existing_tokens = existing_node.split() - # maxpetrusenko P1: dedupe is not enough — the operator may have set - # a CONFLICTING CA-mode flag (e.g. --use-bundled-ca) that would - # otherwise survive alongside our --use-openssl-ca, leaving Node's - # final trust behavior dependent on option order / Node parsing. - # Egress isolation requires our flag to win deterministically, so - # strip any known-conflicting CA-mode flags before appending. - _CA_MODE_FLAGS = {"--use-openssl-ca", "--use-bundled-ca"} - append_token = _egress_node_append.strip() - if append_token in _CA_MODE_FLAGS: - dropped = [t for t in existing_tokens if t in _CA_MODE_FLAGS and t != append_token] - if dropped: - logger.warning( - "Overriding conflicting NODE_OPTIONS CA-mode flag(s) %s " - "with egress-required %s to keep Node routed through the " - "egress CA store.", dropped, append_token, - ) - existing_tokens = [t for t in existing_tokens if t not in _CA_MODE_FLAGS or t == append_token] - # De-dup: only add if not already present (the operator may - # have set the same flag themselves). - if append_token not in existing_tokens: - existing_tokens.append(append_token) - merged_env["NODE_OPTIONS"] = " ".join(existing_tokens).strip() - if not merged_env["NODE_OPTIONS"]: - merged_env.pop("NODE_OPTIONS", None) - env_args = [] - for key in sorted(merged_env): - env_args.extend(["-e", f"{key}={merged_env[key]}"]) + for key in sorted(self._env): + env_args.extend(["-e", f"{key}={self._env[key]}"]) # Optional: run the container as the host user so files written into # bind-mounted dirs (/workspace, /root, docker_volumes entries) are @@ -1269,31 +842,12 @@ class DockerEnvironment(BaseEnvironment): logger.warning("Ignoring non-string docker_extra_args entry: %r", arg) continue validated_extra.append(arg) - if egress_env_overrides: - _extra_collisions = _extra_args_egress_collisions( - validated_extra, _critical_egress_names, - ) - if _extra_collisions: - _msg = ( - f"docker_extra_args would override egress-proxy controls " - f"{_extra_collisions}; enforce_on_docker is " - f"{'enabled' if _enforce_egress else 'disabled'}." - ) - if _enforce_egress: - raise RuntimeError( - f"{_msg} Remove these args or disable enforce_on_docker " - "to opt out of egress isolation." - ) - logger.warning( - "%s Extra Docker args may bypass egress isolation.", _msg, - ) all_run_args = ( security_args + user_args + writable_args + resource_args - + egress_host_args + volume_args + env_args + validated_extra @@ -1315,7 +869,6 @@ class DockerEnvironment(BaseEnvironment): "--label", "hermes-agent=1", "--label", f"hermes-task-id={task_label}", "--label", f"hermes-profile={profile_name}", - "--label", f"{_EGRESS_LABEL_KEY}={egress_label}", ] # Save args for container recreation on "No such container" recovery. self._image = image @@ -1327,7 +880,6 @@ class DockerEnvironment(BaseEnvironment): "hermes-agent": "1", "hermes-task-id": task_label, "hermes-profile": profile_name, - _EGRESS_LABEL_KEY: egress_label, } # Cross-process container reuse (issue #20561 — docs claim "ONE long-lived @@ -1337,15 +889,14 @@ class DockerEnvironment(BaseEnvironment): # restores the documented contract; opt out via # ``terminal.docker_persist_across_processes: false``. # - # Reuse matches on labels only. The egress posture gets its own label - # because env vars, CA mounts, and host mappings are immutable after - # container creation — reusing a pre-egress or pre-rotation container - # would silently bypass the credential firewall. + # Reuse matches on labels only — we deliberately do NOT compare image + # / mounts / resources. Operators who need a fresh container after + # changing those settings should set ``docker_persist_across_processes: + # false`` (or run ``docker rm -f`` against the labeled container) to + # force a clean start. reused = False if persist_across_processes: - existing = self._find_reusable_container( - task_label, profile_name, egress_label, - ) + existing = self._find_reusable_container(task_label, profile_name) if existing is not None: container_id, state = existing self._container_id = container_id @@ -1518,9 +1069,7 @@ class DockerEnvironment(BaseEnvironment): # 1. Try label-based reuse (another process may have recreated it). task_label = self._labels.get("hermes-task-id", "") profile_label = self._labels.get("hermes-profile", "") - existing = self._find_reusable_container( - task_label, profile_label, self._labels.get(_EGRESS_LABEL_KEY, "off"), - ) + existing = self._find_reusable_container(task_label, profile_label) if existing is not None: cid, state = existing if state == "running": @@ -1645,12 +1194,7 @@ class DockerEnvironment(BaseEnvironment): logger.debug("Docker --storage-opt support: %s", _storage_opt_ok) return _storage_opt_ok - def _find_reusable_container( - self, - task_label: str, - profile_label: str, - egress_label: str, - ) -> Optional[tuple[str, str]]: + def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]: """Look for an existing container labeled for this (task, profile). Returns ``(container_id, state)`` on hit, ``None`` on miss / on any @@ -1664,28 +1208,13 @@ class DockerEnvironment(BaseEnvironment): started by some other tool. """ try: - filters = [ - "--filter", "label=hermes-agent=1", - "--filter", f"label=hermes-task-id={task_label}", - "--filter", f"label=hermes-profile={profile_label}", - ] - if egress_label != "off": - filters.extend(["--filter", f"label={_EGRESS_LABEL_KEY}={egress_label}"]) - fmt = "{{.ID}}\t{{.State}}" - else: - # When egress is off, we widen the probe to find any - # task+profile container (regardless of egress label), then - # post-filter in Python: reject containers whose - # hermes-egress label is present and not "off". Without - # this, a container created with egress=on can be silently - # reused after the operator runs "hermes egress disable", - # preserving baked-in proxy env and CA mounts. - fmt = '{{.ID}}\t{{.State}}\t{{.Label "' + _EGRESS_LABEL_KEY + '"}}' result = subprocess.run( [ self._docker_exe, "ps", "-a", - *filters, - "--format", fmt, + "--filter", "label=hermes-agent=1", + "--filter", f"label=hermes-task-id={task_label}", + "--filter", f"label=hermes-profile={profile_label}", + "--format", "{{.ID}}\t{{.State}}", ], capture_output=True, text=True, @@ -1702,7 +1231,7 @@ class DockerEnvironment(BaseEnvironment): result.returncode, result.stderr.strip(), ) return None - lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()] if not lines: return None # Multiple matches are unusual (one (task, profile) should produce one @@ -1713,24 +1242,10 @@ class DockerEnvironment(BaseEnvironment): running = None first = None for ln in lines: - if egress_label == "off": - # Format: ID\tState\tEgressLabel — parse all three fields - # and reject containers with a non-off egress label. - parts = ln.split("\t", 2) - if len(parts) < 3: - continue - cid, state, egress_val = parts[0], parts[1].lower(), parts[2] - if egress_val not in ("", "", "off"): - logger.debug( - "skipping container %s for egress=off reuse: " - "label %s=%r", cid, _EGRESS_LABEL_KEY, egress_val, - ) - continue - else: - parts = ln.split("\t", 1) - if len(parts) != 2: - continue - cid, state = parts[0], parts[1].lower() + parts = ln.split("\t", 1) + if len(parts) != 2: + continue + cid, state = parts[0], parts[1].lower() if first is None: first = (cid, state) if state == "running" and running is None: diff --git a/website/docs/developer-guide/egress-internals.md b/website/docs/developer-guide/egress-internals.md deleted file mode 100644 index f84160788a0..00000000000 --- a/website/docs/developer-guide/egress-internals.md +++ /dev/null @@ -1,318 +0,0 @@ ---- -sidebar_position: 14 -title: "Egress proxy internals" -description: "How the iron-proxy egress firewall integrates with Hermes — module layout, lifecycle, security invariants, and extension points" ---- - -# Egress proxy internals - -This page covers the architecture of the egress credential-injection firewall (`hermes egress` / iron-proxy) from a contributor / plugin author's perspective. End-user setup + usage docs live at [Egress proxy](../user-guide/egress/iron-proxy.md). - -The threat model and high-level design are summarised on the user page; this page is about *how* it's wired, where the security-relevant code lives, and what invariants you have to preserve if you touch it. - -## Module layout - -```text -agent/proxy_sources/iron_proxy.py Core: binary install, CA gen, config build, - subprocess lifecycle, mappings I/O, PID/nonce - defense. Pure-function surface where possible. - -hermes_cli/proxy_cli.py Wizard + slash command handlers. - `hermes egress {install,setup,start,stop, - status,disable,config}`. Wires the - core module into argparse. - -hermes_cli/main.py:_dispatch_egress Top-level subparser dispatcher. - dest='egress_command' (intentionally - disjoint from the inbound OAuth - `hermes proxy` subparser, which uses - dest='proxy_command'). - -hermes_cli/config.py: proxy schema The `proxy:` block in DEFAULT_CONFIG. - Adding a knob means: add it here, add a - wizard prompt or `setdefault` in - proxy_cli.cmd_setup, and document it - in the user-guide page. - -tools/environments/docker.py - _egress_proxy_args_for_docker() Builds the volume_args / env_overrides / - host_args triple that the Docker backend - injects when `proxy.enabled: true`. - - DockerEnvironment.__init__ Docker-side merge logic: collision - detection against critical egress vars, - NODE_OPTIONS append-merge via the - _HERMES_EGRESS_NODE_OPTIONS_APPEND - sentinel, enforce_on_docker precedence. - -tests/test_iron_proxy.py Hermetic tests (~70). Binary install - path, config build, mappings I/O, - subprocess lifecycle, docker arg builder, - deny CIDR defaults, bind policy, CA - TOCTOU, ensure_audit_log behaviour, etc. - -tests/test_iron_proxy_cli.py CLI handler unit tests (~20). Argparse - wiring, fail-loud paths, BWS refresh - wire-up, dest='egress_command' - regression guard. - -tests/test_iron_proxy_e2e.py Live E2E (gated on HERMES_RUN_E2E=1). - Real iron-proxy binary, real curl, - end-to-end token swap verified. -``` - -## Lifecycle - -```text -hermes egress install - -> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...) - Downloads pinned tarball + checksums.txt from GitHub Releases. - SHA-256 verification before extraction. - tarfile.extract(..., filter="data") on Python 3.12+ (PEP 706); - falls back to plain extract on older Python with member-name - sanitisation via _pick_tar_member. - Stage into ~/.hermes/bin/.iron-proxy_XXXX, chmod 755, os.replace - to ~/.hermes/bin/iron-proxy (atomic). - _VERSION_CACHE.pop(target) so a forced reinstall re-probes - --version on next call. - -hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens] - -> proxy_cli.cmd_setup - Step 1. find_iron_proxy(install_if_missing=False) -> install if absent. - Step 2. ensure_ca_cert() - Run openssl genrsa + req via subprocess. - Write CA key via os.open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600) - + os.replace. Never exists on disk under default umask. - Write CA cert with 0o644 (public). - Step 3. discover_provider_mappings() or pull names from BWS via - fetch_bitwarden_secrets() when --from-bitwarden. - merge_mappings(existing=load_mappings(), discovered, - rotate=args.rotate_tokens) preserves prior - tokens unless --rotate-tokens is passed. - discover_uncovered_providers() and surface warnings. - Step 4. ensure_audit_log(audit_log_path) # raises on OSError - build_proxy_config(...) with defaults applied at the call site - (deny CIDRs default, bind policy from _default_http_listen). - write_proxy_config(cfg) # atomic via .tmp + os.replace, 0o600 - write_mappings(mappings) # atomic, 0o600 - Step 5. proxy_cfg["enabled"] = True; credential_source preservation logic - (do NOT silently downgrade bitwarden -> env on re-run); - save_config(cfg). - -hermes egress start - -> proxy_cli.cmd_start - Pre-checks (refuse-start path): - - credential_source=bitwarden? -> pre-validate access_token_env + project_id - -> iron_proxy.start_proxy( - refresh_secrets_from_bitwarden=..., - bitwarden_config=..., - ) - existing=_read_pid(); if alive, idempotent return. - _build_proxy_subprocess_env(...): ALLOWLIST + mapped real_env_names, - strip HTTPS_PROXY/etc. to avoid recursion, optional BWS refresh - (raises on missing values unless allow_env_fallback=true). - Plant nonce: _proxy_nonce = sha256(urandom(16)); env[NONCE_ENV] = ... - Open log_path via O_NOFOLLOW + 0o600 + st_uid check. - Popen with stdin=DEVNULL, stdout=log_fd, stderr=STDOUT, - start_new_session=True (POSIX). - Close parent's log_fd in finally. - _write_pidfile_safely(pidfile, proc.pid) - O_EXCL + O_NOFOLLOW + uid check + persisted nonce sidecar. - FileExistsError -> discriminate live vs stale, retry once if stale. - Install SIGINT/SIGTERM handlers (main-thread only). - Poll loop (do-while shape): - while True: - if proc.poll() is not None: tail log + unlink pidfile + raise - if _port_listening(probe_host, tunnel_port): break # probe_host = configured bind host - if time.time() >= deadline: break (do-while: checked AFTER first probe) - time.sleep(0.1) - If not listening at exit: _kill_and_wait(proc) + unlink pidfile + raise. - -hermes egress stop - -> iron_proxy.stop_proxy - _read_pid + _pid_alive guard. - starttime_before = _pid_proc_starttime(pid) # Linux only; None elsewhere - os.kill(pid, SIGTERM) - Wait up to 5s for graceful exit. - After grace: re-check starttime + _pid_alive. - If recycled (starttime drift OR _pid_alive False), DO NOT SIGKILL. - Otherwise os.kill(pid, _KILL_SIGNAL). - _cleanup_state_files: unlink pidfile + nonce sibling. -``` - -## Security invariants - -These are the load-bearing properties. If you touch the module, you must preserve them. Where there's a regression test, it's named. - -### Filesystem perms - -| Path | Mode | Test | -|---|---|---| -| `~/.hermes/proxy/` (dir) | `0o700` | `test_proxy_state_dir_is_0o700` | -| `ca.key` | `0o600` | `test_ca_key_created_with_0o600` | -| `ca.crt` | `0o644` | (implicit; chmod call in `ensure_ca_cert`) | -| `proxy.yaml` | `0o600` | (chmod after atomic rename in `write_proxy_config`) | -| `mappings.json` | `0o600` | (chmod after atomic rename in `write_mappings`) | -| `iron-proxy.pid` | `0o600` | (`os.open(..., 0o600)` mode in `_write_pidfile_safely`) | -| `iron-proxy.nonce` | `0o600` | (`os.open(..., 0o600)` mode in `_write_pidfile_safely`) | -| `audit.log` | `0o600` | `test_ensure_audit_log_creates_with_0o600` | -| `iron-proxy.log` | `0o600` | (`os.open(..., 0o600)` + `fchmod`) | - -All write paths use `os.open(O_WRONLY | O_CREAT | O_NOFOLLOW, 0o600)` + `os.fstat().st_uid` check. `shutil.copy2` + `os.chmod` is forbidden because it leaks a default-umask window. - -### Subprocess env minimisation - -`_build_proxy_subprocess_env` MUST NOT use `os.environ.copy()`. The allowlist is `_PROXY_SUBPROCESS_ENV_ALLOWLIST` (PATH, HOME, locale, etc.) plus the env names referenced by `load_mappings()`. Everything else stays on the host. - -Regression: `test_subprocess_env_strips_unrelated_secrets`, `test_subprocess_env_strips_proxy_recursion_vars`, `test_subprocess_env_keeps_infrastructure_vars`. - -### Bind policy - -`_default_http_listen` returns a single-element list: on Linux the docker bridge gateway IP (containers reach the proxy via `host.docker.internal:host-gateway`, which resolves to the bridge gateway — a loopback bind is unreachable from inside containers there); on macOS/Windows Docker Desktop, loopback (VPNkit routes `host.docker.internal` to the host). Linux without a detectable docker0 bridge falls back to loopback with a warning. Never `0.0.0.0`, never `:PORT` (INADDR_ANY). - -`_detect_docker_bridge_ip` validates via `ipaddress.IPv4Address` and rejects `is_unspecified` / `is_loopback` / `is_multicast` / `is_reserved` / `is_link_local` / `is_global`. A hostile `ip` shim on PATH cannot inject `0.0.0.0`. - -**v0.39 schema constraint and listener roles (verified live against the binary):** the binary's `config.Proxy` struct has only singular listener fields — there is no `http_listens` (plural) list. `tunnel_listen` is the CONNECT + MITM listener (what `HTTPS_PROXY` traffic hits); `http_listen` only handles absolute-form plain-HTTP forwards (a CONNECT sent to it is relayed upstream as a regular request and 400s). `build_proxy_config` therefore binds `tunnel_listen` on `tunnel_port` and `http_listen` on `tunnel_port + 1`, both on the platform bind host. The Docker backend sets `HTTPS_PROXY` to `tunnel_port` and `HTTP_PROXY` to `tunnel_port + 1`. - -The liveness probes (`start_proxy` poll loop, `get_status`) read the configured bind host via `_read_http_listen_from_config()` and probe THAT host — a hardcoded loopback probe would report a healthy bridge-bound daemon as dead. - -Regression: `test_default_bind_is_loopback_not_zero_zero` (asserts no INADDR_ANY AND that `http_listens` is NOT in the rendered yaml), `test_default_bind_uses_docker_bridge_on_linux`, `test_default_bind_falls_back_to_loopback_without_bridge`, `test_default_bind_is_loopback_on_macos`, `test_detect_docker_bridge_ip_rejects_dangerous` (parametrized over 8 attack inputs). - -### Metrics port collision - -`metrics.listen` defaults to `:9090` in iron-proxy v0.39 — the SAME port as Hermes's default `tunnel_port: 9090`. `build_proxy_config` MUST explicitly pin `metrics.listen: 127.0.0.1:0` so the metrics binding gets an ephemeral loopback port that can never collide with the proxy listener regardless of operator-chosen `tunnel_port`. - -Regression: `test_metrics_listener_pinned_to_loopback_ephemeral`. - -### Default deny CIDRs - -`_DEFAULT_UPSTREAM_DENY_CIDRS` covers loopback (v4 + v6), link-local (incl. IMDS at 169.254.169.254 and the IPv4-mapped-v6 form), RFC1918, IPv6 ULA, CGNAT, and the RFC2544 benchmark range. `build_proxy_config(..., upstream_deny_cidrs=None)` MUST emit the default; only an explicit empty list opts out. - -Regression: `test_default_deny_cidrs_present_when_unspecified`, `test_default_deny_includes_ipv4_mapped_v6`. - -### Audit log fail-loud - -`ensure_audit_log` raises `RuntimeError` on any `OSError`. On the pinned v0.39 the daemon never writes this file (no `log.audit_path` field), so `cmd_setup` treats the failure as a WARNING (the file is non-load-bearing until the version bump) and qualifies the success line as "reserved". When the pin moves to a version with `log.audit_path`, revisit: the pre-create becomes load-bearing for the 0o600-from-first-byte guarantee and the wizard should fail loud again. - -**v0.39 schema constraint:** `log.audit_path` is NOT a field in iron-proxy v0.39's `config.Log` struct, so `build_proxy_config` accepts the `audit_log` kwarg but does NOT emit it into the rendered yaml. Per-request records on v0.39 land in `iron-proxy.log` alongside daemon-level events. The `audit.log` file is still pre-created at `0o600` with `O_NOFOLLOW` so the privacy contract holds when the pinned version is bumped to one that supports the separate stream. - -Regression: `test_ensure_audit_log_raises_on_immutable_parent`, `test_audit_log_kwarg_does_not_inject_audit_path_v039`. - -### Bitwarden mode fail-loud - -When `credential_source: bitwarden` AND `proxy.allow_env_fallback: false` (default): -- Missing access token env var -> `cmd_start` refuses. -- Missing `project_id` -> `cmd_start` refuses. -- `bws secret list` returns no values for one or more mapped providers -> `_build_proxy_subprocess_env` raises. - -Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat. - -Regression: `test_cmd_start_refuses_when_bitwarden_token_missing` (CLI layer); strict-mode assertions in `_build_proxy_subprocess_env` (daemon layer). - -### docker_env collision detection - -When `enforce_on_docker: true`, `docker_env` overrides on any of the egress-controlling vars (HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, etc.) OR any mapped `real_env_name` (OPENROUTER_API_KEY, etc.) raises `RuntimeError` BEFORE the container starts. - -Regression: `test_docker_env_collision_with_proxy_raises_when_enforce`. - -### PID recycling defense - -`_pid_alive` MUST consult either the in-process `_proxy_nonce` (same-process case) OR the on-disk `iron-proxy.nonce` (cross-CLI case) before trusting an `argv[0]` basename match. `stop_proxy` MUST re-check `/proc//stat` starttime before SIGKILL and suppress the signal on starttime drift. - -Regression: `test_stop_proxy_suppresses_sigkill_on_pid_recycle`, `test_pid_proc_starttime_parses_comm_with_parens`, `test_persisted_nonce_roundtrip`. - -### Token preservation on re-setup - -`merge_mappings(existing, discovered, rotate=False)` MUST return prior tokens for providers that overlap. Re-running `hermes egress setup` cannot silently 401 running sandboxes. `--rotate-tokens` is the explicit opt-in. - -Regression: `test_merge_mappings_preserves_existing_tokens`, `test_merge_mappings_rotate_mints_fresh_tokens`. - -### `credential_source` preservation - -`cmd_setup` MUST NOT downgrade `credential_source: bitwarden` to `env` on re-run without an explicit `--no-bitwarden` flag. Running `hermes egress setup` (no flag) preserves whatever was previously configured. - -Tested via the `cmd_setup` flow in CLI tests (the bitwarden-preservation path is exercised when `--from-bitwarden` is followed by a plain `setup` re-run). - -## Extension points - -### Adding a new bearer-token provider - -`_BEARER_PROVIDERS` in `iron_proxy.py` maps env var name -> tuple of upstream hosts. Adding an entry makes it discoverable by `discover_provider_mappings()`; the wizard mints a token for it automatically when the env var is present. - -```python -_BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { - ..., - "MY_PROVIDER_API_KEY": ("api.myprovider.com",), -} -``` - -Also update `_DEFAULT_ALLOWED_HOSTS` so the proxy allows the upstream by default. Run `test_discover_provider_mappings_*` to confirm. - -### Adding a new header-token provider (x-api-key family) - -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_SIGNED_PROVIDER_ACCESS_KEY", -) -``` - -### Wiring iron-proxy into a non-Docker backend - -`_egress_proxy_args_for_docker` is Docker-specific. Backends that want similar wiring need their own analogue that: - -1. Reads `load_config().get("proxy", {})`; returns empty args if `enabled` is false. -2. Calls `iron_proxy.get_status()`; surfaces `enforce` semantics on `configured` / `pid` / `listening` / `ca_cert_path` failure paths. -3. Calls `iron_proxy.load_mappings()`; refuses to mount if empty AND `enforce_on_docker: true`. -4. Sets the seven env vars (HTTPS_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, HERMES_EGRESS_PROXY) and the per-mapping `HERMES_PROXY_TOKEN_` vars. -5. Distributes the CA cert into the sandbox at a path the runtime will trust (typically `/etc/ssl/certs/hermes-egress-ca.crt`). -6. Implements collision detection against the user's backend-specific env config. - -The Docker implementation is ~150 lines; expect similar volume for Modal / Daytona / SSH. - -### Subscribing to per-request audit events - -iron-proxy writes line-delimited JSON to `~/.hermes/proxy/iron-proxy.log` on the currently pinned v0.39 (daemon + per-request records combined; see "Logging on iron-proxy v0.39" in the user guide). A plugin / external watcher can tail that file and react to allowlist denials, secret swaps, or upstream errors. When the pinned version is bumped to one that supports `log.audit_path`, the per-request stream moves to `audit.log` and watchers wired to that path go live without operator action. The schema is documented at [docs.iron.sh/audit](https://docs.iron.sh/audit) (link). - -## Testing - -```bash -# Hermetic suite (no network, no real binary) -scripts/run_tests.sh tests/test_iron_proxy.py tests/test_iron_proxy_cli.py - -# Live E2E (real binary, real curl, real CONNECT tunnel) -HERMES_RUN_E2E=1 scripts/run_tests.sh tests/test_iron_proxy_e2e.py - -# Live PTY smoke against `hermes egress` -HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress --help -HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress setup --help -``` - -The CLI uses argparse, so `--help` is a good first probe for "did my new flag register correctly". - -## See also - -- User-facing setup + troubleshooting: [Egress proxy](https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy) -- Docker backend internals: [Docker](https://hermes-agent.nousresearch.com/docs/user-guide/docker) -- Bitwarden Secrets Manager integration: [`hermes secrets bitwarden`](https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden) -- CLI command reference: [`hermes egress`](https://hermes-agent.nousresearch.com/docs/reference/cli-commands#hermes-egress) -- Sandbox-injected environment variables: [Egress proxy (sandbox-injected)](https://hermes-agent.nousresearch.com/docs/reference/environment-variables#egress-proxy-sandbox-injected) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 5e2999f7242..907af9c2402 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -273,8 +273,6 @@ hermes config set terminal.backend docker # Docker isolation hermes config set terminal.backend ssh # Remote server ``` -For Docker sandboxes, you can also enable the **egress credential-injection proxy** so the sandbox never sees your real API keys — only opaque proxy tokens that work exclusively from behind a local TLS-intercepting daemon. See [Egress proxy](../user-guide/egress/iron-proxy.md). Setup is `hermes egress setup && hermes egress start`; `hermes setup terminal` also points Docker users at it. Modal, SSH, Daytona, and Singularity are not wired yet. - ### Voice mode ```bash diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 40a8a6837f2..4717197cbd4 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -43,7 +43,6 @@ hermes [global-options] [subcommand/options] | `hermes fallback` | Manage fallback providers tried when the primary model errors. | | `hermes gateway` | Run or manage the messaging gateway service. | | `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). | -| `hermes egress` | Outbound credential-injection firewall for remote terminal sandboxes (iron-proxy). Disabled by default. See [Egress proxy](../user-guide/egress/iron-proxy.md). | | `hermes lsp` | Manage Language Server Protocol integration (semantic diagnostics for write_file/patch). | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | @@ -619,67 +618,6 @@ All actions are also available as a slash command in the gateway (`/kanban …`) For the full design — comparison with Cline Kanban / Paperclip / NanoClaw / Gemini Enterprise, eight collaboration patterns, four user stories, concurrency correctness proof — see `docs/hermes-kanban-v1-spec.pdf` in the repository or the [Kanban user guide](/user-guide/features/kanban). -## `hermes egress` - -Outbound credential-injection firewall for remote terminal sandboxes. Wraps the [iron-proxy](https://github.com/ironsh/iron-proxy) daemon — a TLS-intercepting proxy that swaps opaque proxy tokens for real upstream API credentials at the network boundary, so sandboxes never hold real keys. Disabled by default; see the full [Egress proxy](../user-guide/egress/iron-proxy.md) page for setup + architecture. - -```bash -hermes egress install # download the pinned iron-proxy binary -hermes egress install --force # re-download even if already installed - -hermes egress setup # interactive wizard: CA, mappings, config -hermes egress setup --tunnel-port N # override the tunnel listener port (default 9090) -hermes egress setup --from-bitwarden # use Bitwarden Secrets Manager as credential source -hermes egress setup --no-bitwarden # explicitly switch back to env-based credentials -hermes egress setup --rotate-tokens # mint fresh proxy tokens (default preserves existing) - -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 — needed for secret changes -hermes egress reload # hot-reload the ruleset in-place (no restart, no dropped - # connections) via the loopback management API - -hermes egress status # binary + config + pid + listening + mappings -hermes egress status --show-tokens # print proxy tokens in full (default: redacted) - -hermes egress disable # flip proxy.enabled = false (does not stop a running proxy) -hermes egress config # print the path to proxy.yaml for inspection -``` - -### Common flows - -```bash -# First-time setup -export OPENROUTER_API_KEY=… -hermes egress setup && hermes egress start -hermes config set terminal.backend docker # if not already - -# Switching credential source after the fact -hermes egress setup --from-bitwarden # env → bitwarden -hermes egress setup --no-bitwarden # bitwarden → env -# (just `setup` without either flag preserves the existing mode) - -# Rotating all tokens (e.g. after a suspected token leak) -hermes egress setup --rotate-tokens # setup offers to restart the running daemon for you -# (running sandboxes still hold old tokens; restart them too) - -# Adding a new upstream -# Edit ~/.hermes/config.yaml proxy.extra_allowed_hosts: [api.example.com] -hermes egress setup -hermes egress restart # one-command apply (stop + start) -``` - -### Diagnostic shortcuts - -```bash -hermes egress status # current state in one view -cat ~/.hermes/proxy/proxy.yaml # the rendered iron-proxy config -tail -20 ~/.hermes/proxy/iron-proxy.log # daemon-level diagnostics -tail -f ~/.hermes/proxy/iron-proxy.log | jq # daemon + per-request log (line-delimited JSON; v0.39 combines both streams) -``` - -Common failure modes + recovery are covered in [Egress proxy → Troubleshooting](../user-guide/egress/iron-proxy.md#troubleshooting). - ## `hermes project` ```bash diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 7d84caa572e..9f4aa214453 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -248,23 +248,6 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TERMINAL_LOCAL_PERSISTENT` | Enable persistent shell for local backend (default: `false`) | | `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH backend (default: follows `TERMINAL_PERSISTENT_SHELL`) | -## Egress proxy (sandbox-injected) - -These env vars are NOT set on the host — they're injected into Docker sandboxes by the [Egress proxy](../user-guide/egress/iron-proxy.md) integration when `proxy.enabled: true`. Docker is the only wired backend in this release. - -| Variable | Description | -|----------|-------------| -| `HERMES_EGRESS_PROXY` | Set to `1` inside a sandbox when the egress proxy is active. Agent code can check this to know it's running behind a TLS-intercepting proxy. | -| Provider env vars (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, …) | Set to opaque proxy tokens, not real upstream secrets, so existing SDKs keep reading the standard env names. iron-proxy swaps those tokens for the real upstream secret at the network boundary. | -| `HERMES_PROXY_TOKEN_` | Diagnostic alias for each minted provider mapping. E.g. `HERMES_PROXY_TOKEN_OPENROUTER_API_KEY=hermes-proxy-openrouter-…`. Same token value as the standard provider env var. | -| `HTTPS_PROXY` / `HTTP_PROXY` | `HTTPS_PROXY` points at `http://host.docker.internal:` for CONNECT/MITM. `HTTP_PROXY` points at `` for plain-HTTP forwarding. | -| `NO_PROXY` | `127.0.0.1,localhost,::1` so loopback dev servers inside the sandbox bypass the proxy. | -| `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` | Path to the mounted Hermes egress CA cert inside the sandbox (`/etc/ssl/certs/hermes-egress-ca.crt`). Lets the language runtimes trust iron-proxy's MITM-minted leaf certs. | -| `NODE_OPTIONS` | Appended with `--use-openssl-ca` (your existing flags are preserved) so Node.js routes through the OpenSSL store the other CA-bundle vars control. Narrows the [Node.js asymmetric CA caveat](../user-guide/egress/iron-proxy.md#nodejs-asymmetric-ca-caveat). | -| `HERMES_IRON_PROXY_NONCE` | Set on the iron-proxy daemon process itself (NOT inside the sandbox). Used by `_pid_alive` to confirm a candidate PID still refers to *our* managed binary across PID recycling. | - -These are set automatically by the Docker terminal backend when `proxy.enabled: true` AND the daemon is running. You don't set them yourself; the relevant operator-facing knobs are in `~/.hermes/config.yaml` under the `proxy:` section — see [Egress proxy → Configuration](../user-guide/egress/iron-proxy.md#configuration). - ## Messaging | Variable | Description | diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 5713cdd450c..1f4254667ca 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -55,7 +55,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/moa ` | Run a single prompt through the default [Mixture of Agents](/user-guide/features/mixture-of-agents) preset, then restore your current model. One-shot — does not change your session model. | | `/resume [name]` | Resume a previously-named session | | `/sessions` (TUI alias: `/switch`) | Classic CLI: browse and resume previous sessions in an interactive picker. TUI: open the live session switcher for currently open TUI sessions. Use `/sessions new` in the TUI to start another live session immediately. | -| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. | | `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) | | `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | @@ -255,7 +254,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. -- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. +- `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts. diff --git a/website/docs/user-guide/egress/index.md b/website/docs/user-guide/egress/index.md deleted file mode 100644 index 90ccbb8a05e..00000000000 --- a/website/docs/user-guide/egress/index.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Egress proxy -sidebar_position: 1 ---- - -# Egress proxy - -Optional outbound credential-injection firewall for remote terminal sandboxes. The sandbox only ever holds opaque proxy tokens; real API keys never leave the host. - -- [iron-proxy](./iron-proxy) — single-binary TLS-intercepting proxy from [ironsh/iron-proxy](https://github.com/ironsh/iron-proxy), lazy-installed and managed by `hermes egress`. diff --git a/website/docs/user-guide/egress/iron-proxy.md b/website/docs/user-guide/egress/iron-proxy.md deleted file mode 100644 index f38cbfa33a8..00000000000 --- a/website/docs/user-guide/egress/iron-proxy.md +++ /dev/null @@ -1,570 +0,0 @@ -# Egress credential-injection proxy (iron-proxy) - -When Hermes runs your agent inside a Docker terminal sandbox, that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them. - -The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work behind the **configured trusted proxy boundary** — the CA private key and the proxy endpoint integrity are part of that boundary. If traffic can be redirected to attacker-controlled proxy infrastructure (e.g. a stolen CA private key or a hijacked proxy endpoint), the token guarantee no longer holds. - -This release wires the egress proxy into the Docker backend only. Modal, Daytona, SSH, and Singularity do **not** receive proxy env vars or CA mounts yet. - -## What it is - -- A managed `iron-proxy` subprocess on the host, lazy-installed into `~/.hermes/bin/iron-proxy` -- A local CA at `~/.hermes/proxy/ca.crt` that the sandbox trusts so iron-proxy can MITM TLS and rewrite headers -- A `proxy.yaml` config at `~/.hermes/proxy/proxy.yaml` listing the upstream hosts you allow and the secrets-transform mapping -- A `mappings.json` recording which proxy token corresponds to which real env var - -The sandbox gets `HTTPS_PROXY=http://host.docker.internal:9090`, `HTTP_PROXY=http://host.docker.internal:9091`, and standard provider env vars such as `OPENROUTER_API_KEY` set to opaque proxy tokens. Matching `HERMES_PROXY_TOKEN_` aliases are also exported for diagnostics. Existing provider SDKs read the usual env names, send the proxy token in `Authorization`, and iron-proxy's `secrets` transform substitutes the real value sourced from the host-side daemon environment. - -## What it is not - -- It is **not** the inbound `hermes proxy` command, which is an OAuth aggregator reverse proxy. Different command (`hermes egress`), different direction. -- It does **not** sit between your local terminal and providers — only between the sandbox and providers. -- It does **not** rewrite credentials for in-process LLM calls the host process makes. Those continue to use your `.env` keys directly. The threat model is the *sandbox*, not the host. - -## Quick start - -```bash -# 1. Install the iron-proxy binary (pinned version, SHA-256 verified) -hermes egress install - -# 2. Run the wizard: generates CA, mints proxy tokens for every provider key -# in your env, writes proxy.yaml. -hermes egress setup - -# 3. Start the proxy daemon -hermes egress start - -# 4. Check status -hermes egress status -``` - -`hermes egress setup` discovers provider keys from your environment. If your keys live only in `~/.hermes/.env` (not exported into your shell), setup reads that file automatically — you don't have to `export` them first. - -When you re-run `setup` later (new allowlist host, rotated tokens, switched credential source), it stops the running daemon because its config is held in memory, then **offers to restart it for you** so the change takes effect immediately. On a tty it asks; pass `--restart` to always restart or `--no-restart` to leave it down. To apply changes any other time, `hermes egress restart` is the one-command stop-then-start. - -Once running, the Docker terminal backend automatically: - -- Mounts `~/.hermes/proxy/ca.crt` into the sandbox at `/etc/ssl/certs/hermes-egress-ca.crt` -- Sets `HTTPS_PROXY`, `HTTP_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` to make every common HTTP runtime route through the proxy and trust the CA -- Sets `NODE_OPTIONS=--use-openssl-ca` (appended to whatever you already have in `docker_env.NODE_OPTIONS`) so Node.js routes through the OpenSSL store the other CA-bundle vars control — see [Node.js asymmetric CA caveat](#nodejs-asymmetric-ca-caveat) below for the residual gap -- Adds `--add-host=host.docker.internal:host-gateway` so the sandbox can reach the host-side proxy on Linux (Docker Desktop handles this automatically on macOS/Windows) -- Exports the proxy token under the standard provider env name (for example `OPENROUTER_API_KEY`) plus one `HERMES_PROXY_TOKEN_` diagnostic alias per minted mapping - -## Configuration - -The full config lives in `~/.hermes/config.yaml` under the `proxy:` section. Defaults are documented inline; everything is optional. - -```yaml -proxy: - # Master switch. When false the feature is a complete no-op — no - # binaries downloaded, no docker mounts added, no subprocess started. - enabled: false - - # Tunnel listener port. Sandboxes hit http://host.docker.internal:. - tunnel_port: 9090 - - # Auto-download the pinned iron-proxy binary on first use. - auto_install: true - - # Where iron-proxy looks up the real upstream secrets at egress time. - # env — process env (default). Whatever is in your ~/.hermes/.env - # at proxy-start time is the source of truth. - # bitwarden — refetch from Bitwarden Secrets Manager on each proxy - # restart. Rotation in the BW web app propagates without - # touching .env. Requires `secrets.bitwarden.enabled: true`. - credential_source: env - - # When true (default), the Docker backend refuses to start a sandbox if - # the proxy is enabled but not running. Set to false to fall back to the - # legacy "real credentials inside the sandbox" posture when the proxy - # is unavailable. - enforce_on_docker: true - - # 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 - # asked for rotation — don't silently use stale env values"). Set - # to true to opt back into the legacy host-env fallback — useful for - # migrations where you want to start switching to BW mode but haven't - # wired every secret yet. - allow_env_fallback: false - - # SSRF deny list applied to outbound traffic. Omit / leave null to - # use the safe default: loopback (v4 + v6), link-local (incl. cloud - # metadata IPs at 169.254.169.254), RFC1918, IPv6 ULA, IPv4-mapped-v6, - # CGNAT, and the RFC2544 benchmark range. Set to an explicit `[]` - # to opt out entirely (only sensible in hermetic tests). - upstream_deny_cidrs: null - - # Extra allowed upstream hosts beyond the bundled defaults. - # Wildcards (`*.foo.com`) are supported. The defaults cover OpenRouter, - # OpenAI, Anthropic, Google, xAI, Mistral, Groq, Together, DeepSeek, - # and Nous Research. - extra_allowed_hosts: [] -``` - -### Default allowed upstream hosts - -``` -openrouter.ai *.openrouter.ai -api.openai.com api.anthropic.com -generativelanguage.googleapis.com -api.x.ai api.mistral.ai -api.groq.com api.together.xyz -api.deepseek.com inference.nousresearch.com -``` - -If your agent needs an upstream that isn't on the list — a self-hosted inference endpoint, an extra cloud LLM, an MCP server — add it to `proxy.extra_allowed_hosts`. Wildcards are matched against the full hostname (`*.example.com` matches `api.example.com` and `staging.example.com` but not `example.com` itself). - -### Default SSRF deny CIDRs - -Applied regardless of allowlist. These ranges are refused by iron-proxy at the network boundary, so a DNS rebinding attack via an allowlisted hostname can't reach IMDS or your internal network: - -| CIDR | Purpose | -|---|---| -| `127.0.0.0/8`, `::1/128` | Loopback (v4 + v6) | -| `169.254.0.0/16`, `fe80::/10` | Link-local — **incl. AWS / GCP / Azure IMDS at `169.254.169.254`** | -| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | RFC1918 | -| `fc00::/7` | IPv6 ULA | -| `::ffff:0:0/96` | IPv4-mapped IPv6 — closes the dual-stack IMDS bypass | -| `100.64.0.0/10` | RFC6598 CGNAT (used by AWS VPC, K8s pod networks) | -| `198.18.0.0/15` | RFC2544 benchmark range | - -To override: set `proxy.upstream_deny_cidrs` to your own list. To opt out entirely (e.g. for a hermetic test that needs to reach a loopback upstream): set it to an empty list `[]`. - -### Bind policy - -The proxy never binds `0.0.0.0`. The default bind is platform-specific because iron-proxy v0.39 supports only a **single bind per daemon process**: - -- **Linux:** the docker bridge gateway (`172.17.0.1:` by default). Containers reach the proxy via `host.docker.internal`, which `--add-host=host.docker.internal:host-gateway` resolves to exactly this bridge gateway IP — a loopback-only bind would be unreachable from inside sandboxes. The bridge IP is an address on the host's `docker0` interface, so it is not exposed to the LAN; it IS reachable by other containers on the default bridge network, but requests still require a minted proxy token and an allowlisted upstream. If no docker bridge is detected (docker not installed/running), the bind falls back to loopback with a warning. -- **macOS / Windows Docker Desktop:** loopback (`127.0.0.1:`). Desktop's VPNkit routes `host.docker.internal` to the host, so loopback is reachable from containers and is the least-exposed choice. - -A LAN peer with a leaked proxy token cannot use the proxy — neither bind is reachable from the external network. - -We also pin `metrics.listen: 127.0.0.1:0` so the daemon's built-in metrics server gets an ephemeral loopback port instead of its default `:9090` — otherwise it would fight `tunnel_port: 9090` for the same socket and the daemon would refuse to start with "address already in use". Note the `:0` ephemeral port is random per start and not surfaced anywhere, so metrics are effectively disabled at this pin. - -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 - -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 | -|---|---|---| -| `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 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 - -If you already use Bitwarden Secrets Manager via [`hermes secrets bitwarden setup`](../secrets/bitwarden), the egress proxy can pull real credentials from there instead of `os.environ`: - -```bash -hermes egress setup --from-bitwarden -``` - -This sets `proxy.credential_source: bitwarden` and discovers provider env names from your BW project. - -### Rotation semantics - -When `credential_source: bitwarden`, the iron-proxy daemon refetches secrets from BWS via `bws secret list ` **every time it starts**. So the rotation flow is: - -1. Rotate a key in the Bitwarden web app. -2. `hermes egress stop && hermes egress start` on the host. -3. Sandboxes started after that point swap proxy tokens for the new value. - -No `.env` edits. No Hermes restart on the host. The proxy daemon is the only thing that touches the new value — your host process and `os.environ` are untouched. - -### Fail-loud at start - -When `credential_source: bitwarden`, `hermes egress start` pre-checks at the wizard layer AND `_build_proxy_subprocess_env` re-checks at the daemon layer: - -- BWS access token env var is unset → refuse to start with a hint to `unset` and re-run, or `hermes egress setup --no-bitwarden` to switch back to env mode -- `secrets.bitwarden.project_id` is empty → refuse to start with a hint to run `hermes secrets bitwarden setup` -- `bws secret list` returns no values for one or more mapped providers → refuse to start, listing the missing names - -This is intentional. Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat (operator picked BW for the rotation guarantee; silent fallback breaks that guarantee). - -The `proxy.allow_env_fallback: true` config flag opts back in to the legacy "silently fall back to host env if BWS is unreachable" behavior for migration scenarios. Use it when you're moving secrets into BW one at a time and want the daemon to start with whichever values are available. - -### Switching credential source - -| From | To | Command | -|---|---|---| -| env | bitwarden | `hermes egress setup --from-bitwarden` | -| bitwarden | env | `hermes egress setup --no-bitwarden` | - -**Re-running `hermes egress setup` WITHOUT either flag preserves the existing `credential_source`** — the wizard refuses to silently downgrade you back to env. This matters because once you've configured bitwarden mode, the rotation guarantee is what you signed up for; you have to explicitly say "I want env again" to change it. - -## Slash commands - -The CLI subcommand tree: - -``` -hermes egress install # download the pinned iron-proxy binary -hermes egress install --force # re-download even if a managed copy exists - -hermes egress setup # interactive wizard -hermes egress setup --tunnel-port N # override the tunnel listener port -hermes egress setup --from-bitwarden # use BWS as credential source (fail-loud) -hermes egress setup --no-bitwarden # explicitly switch back to env mode -hermes egress setup --rotate-tokens # mint fresh tokens for every provider - # (default preserves existing) - -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 — 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 - # (default: redacted prefix + suffix only) - -hermes egress disable # flip proxy.enabled = false - # (does not stop a running proxy) - -hermes egress config # print the path to proxy.yaml for debugging -``` - -### Token rotation - -By default, `hermes egress setup` **preserves** proxy tokens for providers that already have them. Adding a new provider mints a fresh token only for the new one; existing tokens are unchanged. This avoids 401-ing running sandboxes when you re-run the wizard. - -`--rotate-tokens` rolls every token: - -```bash -hermes egress setup --rotate-tokens -``` - -When there are existing tokens AND stdin is a tty, the wizard prompts for confirmation: - -``` -⚠ --rotate-tokens will invalidate proxy tokens in every running - Hermes sandbox. They will start 401-ing against upstreams until restarted. -Type 'rotate' to confirm: -``` - -Non-tty invocations (CI, scripts) skip the prompt — the flag is treated as deliberate. Before any overwrite the current `mappings.json` is copied to a timestamped sibling so manual recovery is possible: - -``` -backup: ~/.hermes/proxy/mappings.json.rotated-20260524T143012 -``` - -`hermes egress setup` stops a running daemon when it rewrites config or token mappings, because the daemon keeps the old YAML in memory. After `--rotate-tokens`: - -```bash -hermes egress start -``` - -Containers already running hold the old tokens and will need to be restarted to pick up the new ones. New persistent Docker containers include an egress-posture label, so Hermes will not reuse a pre-egress or pre-rotation container for new sessions. - -## State directory layout - -Everything iron-proxy maintains lives in `~/.hermes/proxy/`: - -| Path | Mode | Purpose | -|---|---|---| -| `~/.hermes/proxy/` (dir) | `0o700` | Owned + traversable by you only | -| `ca.crt` | `0o644` | Public CA cert distributed into sandboxes | -| `ca.key` | `0o600` | CA signing key — never leaves the host | -| `proxy.yaml` | `0o600` | iron-proxy config; rewritten every `setup` | -| `mappings.json` | `0o600` | Sandbox proxy token → upstream env var | -| `mappings.json.rotated-*` | `0o600` | Backups created by `--rotate-tokens` | -| `iron-proxy.pid` | `0o600` | PID of the running daemon | -| `iron-proxy.nonce` | `0o600` | Per-start nonce for PID-recycle defense | -| `iron-proxy.log` | `0o600` | Daemon stdout/stderr — **includes per-request records on v0.39** | -| `audit.log` | `0o600` | Reserved for the dedicated per-request audit stream on future binary versions; pre-created so the privacy contract holds when upstream wires it in | - -The CA private key is the most sensitive file. It's created with `0o600` from the first byte (no umask-window TOCTOU) and `O_NOFOLLOW` so a same-uid attacker can't redirect it via a planted symlink. The pidfile, nonce file, daemon log, and audit log get the same treatment. - -### Logging on iron-proxy v0.39 - -On the currently pinned binary version (**v0.39.0**) iron-proxy writes ALL output — daemon-level diagnostics AND per-request records — to **`~/.hermes/proxy/iron-proxy.log`**. v0.39's `config.Log` struct doesn't have a separate `audit_path` field, so we can't route per-request records to a dedicated stream there. - -We still pre-create `~/.hermes/proxy/audit.log` at `0o600` with `O_NOFOLLOW` because: - -1. It reserves the path for the future version bump: when the pinned version moves to one that supports `log.audit_path`, per-request records will start flowing there without operator-side reconfiguration. **Until then the file stays at 0 bytes — do not point monitoring, alerting, or forensics tooling at it yet.** Use `iron-proxy.log` for everything today. -2. The 0o600-from-first-byte guarantee defends against the upstream-fix-day where v0.40+ creates the file under its default umask if it doesn't already exist. - -Until that version bump lands, treat `iron-proxy.log` as the source of truth for both audiences: - -- Daemon-level events (startup banner, bind errors, shutdown reason, transform errors). Operations + troubleshooting. -- Per-request records (CONNECT to allowlisted upstream, secret swap fired, allowlist denial). Forensics + compliance. - -Both files are appended to across restarts. Rotate them with logrotate if you care about disk usage on long-lived hosts. - -## How it works - -``` -┌──────────────┐ ┌──────────────┐ ┌─────────────┐ -│ Docker │ CONNECT / │ iron-proxy │ HTTPS w/ │ OpenRouter │ -│ sandbox ├──────────────▶│ (host:9090) ├───────────────▶│ / OpenAI / │ -│ │ HTTP forward │ │ real API key │ Anthropic … │ -│ has: │ w/ proxy tok │ mints leaf │ │ │ -│ - proxy tok │ in Auth hdr │ cert from CA │ │ │ -│ - CA cert │ │ matches token │ │ │ -│ - HTTPS_PROXY│ │ swaps secret │ │ │ -└──────────────┘ └──────────────┘ └─────────────┘ - │ - │ daemon + per-request log (combined on v0.39) - ▼ - ~/.hermes/proxy/iron-proxy.log - (~/.hermes/proxy/audit.log reserved for v0.40+ split stream) -``` - -1. Sandbox makes an HTTPS request, e.g. `POST https://openrouter.ai/v1/chat/completions` with `Authorization: Bearer hermes-proxy-openrouter-…` (the proxy token, not the real key). -2. Because `HTTPS_PROXY` is set, the request goes to iron-proxy as a CONNECT tunnel. -3. iron-proxy checks the allowlist. `openrouter.ai` is allowed. -4. iron-proxy mints a leaf cert signed by our CA for `openrouter.ai`, terminates the TLS connection, inspects the request. -5. The `secrets` transform matches the proxy-token string in the `Authorization` header and substitutes the real `OPENROUTER_API_KEY` value, sourced from iron-proxy's own environment. -6. Request is re-encrypted and forwarded to OpenRouter. -7. The request is logged to `~/.hermes/proxy/iron-proxy.log` on v0.39. When the pinned binary version supports the split stream (v0.40+), per-request records will flow to `~/.hermes/proxy/audit.log` and daemon-level diagnostics will stay in `iron-proxy.log`. See [Logging on iron-proxy v0.39](#logging-on-iron-proxy-v039). - -A request to a non-allowlisted host (e.g. `https://attacker.example.com/leak?key=...`) is rejected with HTTP 403 before any bytes leave the host. The denial is recorded in `iron-proxy.log` with the upstream host and the source sandbox. - -### CA distribution into the sandbox - -When the Docker backend starts a container with `proxy.enabled: true` and the daemon is listening, it adds these arguments to `docker run`: - -| Arg | Purpose | -|---|---| -| `-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro` | Read-only mount of the CA | -| `-e HTTPS_PROXY=http://host.docker.internal:9090` | Python httpx / curl / go default transport / Node fetch | -| `-e HTTP_PROXY=http://host.docker.internal:9091` | curl + wget for plain HTTP — the plain-HTTP forward listener lives on `tunnel_port + 1` | -| `-e NO_PROXY=127.0.0.1,localhost,::1` | Loopback dev servers inside the sandbox bypass the proxy | -| `-e REQUESTS_CA_BUNDLE=…ca.crt` | Python `requests` | -| `-e SSL_CERT_FILE=…ca.crt` | Python `ssl` module / OpenSSL — **replaces** the system store | -| `-e CURL_CA_BUNDLE=…ca.crt` | curl — **replaces** the system store | -| `-e NODE_EXTRA_CA_CERTS=…ca.crt` | Node.js — **adds** to the system store | -| `-e NODE_OPTIONS=" --use-openssl-ca"` | Node.js — route through OpenSSL store (appended; your `--max-old-space-size` etc. are preserved) | -| `-e HERMES_EGRESS_PROXY=1` | Sentinel the agent can read to know it's proxy-aware | -| `-e OPENROUTER_API_KEY=` | Standard provider env names receive proxy tokens so existing SDKs keep working | -| `-e HERMES_PROXY_TOKEN_=…` | Diagnostic alias for each mapping; same value as the standard provider env var | -| `--add-host=host.docker.internal:host-gateway` | Linux-only; Docker Desktop maps it automatically | - -#### Node.js asymmetric CA caveat - -`REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` **replace** the system CA store inside the sandbox. `NODE_EXTRA_CA_CERTS` **adds** to it. A Node.js process inside the sandbox could in principle bypass the proxy by opening a raw `net.Socket` and starting its own TLS handshake — the system CA store would still trust real upstream certs, so the request would succeed where Python / curl would fail validation. - -`NODE_OPTIONS=--use-openssl-ca` is appended to whatever you already have in `docker_env.NODE_OPTIONS`. This forces Node through the OpenSSL store that `SSL_CERT_FILE` controls, narrowing the asymmetry. It does NOT cover code that explicitly passes its own `ca` option to `tls.connect()` or `https.request()`, but it closes the easy case. - -This is a known v1 limitation. Track [github.com/ironsh/iron-proxy/issues](https://github.com/ironsh/iron-proxy/issues) for an upstream resolution; in the meantime, do not run untrusted Node code that opens raw sockets in a sandbox you're depending on egress isolation for. - -### docker\_env collisions - -If you set proxy-controlling env vars in your `docker_env:` config block (rare but possible), Hermes refuses to start the sandbox when `enforce_on_docker: true` is set. This includes both: - -- Egress-control vars: `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` -- Real provider env vars: every name in `mappings.json` (e.g. `OPENROUTER_API_KEY`, `OPENAI_API_KEY`) - -Example error: - -``` -docker_env in config.yaml overrides egress-proxy variables -['HTTPS_PROXY', 'OPENROUTER_API_KEY']; enforce_on_docker is enabled. -Remove these keys from docker_env or disable enforce_on_docker to -opt out of egress isolation. -``` - -With `enforce_on_docker: false` the same situation surfaces as a warning and your `docker_env` values win — useful for migrations or testing, but you're explicitly opting OUT of the isolation guarantee. - -## PID and nonce defense - -The daemon's pidfile is written with `O_EXCL` + `O_NOFOLLOW` + ownership check. Concurrent `hermes egress start` calls produce one of two outcomes: - -- The existing pidfile points at a live iron-proxy → second start refuses with "another start in progress" + a hint to run `hermes egress stop` -- The existing pidfile is stale (crashed daemon) → second start unlinks it and retries once - -Beyond that, every `start_proxy` plants a fresh random nonce in two places: - -- `HERMES_IRON_PROXY_NONCE=` in the daemon's env -- `~/.hermes/proxy/iron-proxy.nonce` (0o600 sibling of the pidfile) - -When `hermes egress stop` (or any other `_pid_alive` check) wants to confirm a PID still refers to *our* daemon — not an unrelated process that was assigned the same PID after iron-proxy crashed — it reads `/proc//environ` and looks for the nonce. The on-disk copy is what makes this work across CLI invocations (the in-memory `_proxy_nonce` is per-process and resets on every `hermes` invocation). - -If the nonce check fails, the code falls back to matching `argv[0]` basename against `iron-proxy`. `stop_proxy` additionally captures `/proc//stat` starttime before SIGTERM and re-verifies after the 5s grace window — if starttime drifted, the PID was recycled mid-wait and SIGKILL is suppressed with a warning. - -## Security model - -**What this protects against:** - -- Prompt-injected agent in a Docker sandbox reading `printenv` / credential files and exfiltrating real keys. -- Compromised dependency in the sandbox phoning home to an arbitrary host — default-deny allowlist blocks unknown destinations. -- Agent dialing cloud metadata endpoints (`169.254.169.254`) — iron-proxy denies these by default via `upstream_deny_cidrs`, including the IPv4-mapped-v6 form `::ffff:169.254.169.254`. -- DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time. -- Same-uid local processes reading the iron-proxy daemon's env to scrape secrets — only the env var names referenced by mappings are forwarded, not the full host env. -- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds the docker bridge gateway (Linux) or loopback (Docker Desktop), never `0.0.0.0`, so it is unreachable from the external network. - -**What it does NOT protect against:** - -- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise. -- **Loss of the trusted-proxy boundary itself.** The token-swap guarantee assumes the sandbox trusts the mounted CA cert (`/etc/ssl/certs/hermes-egress-ca.crt`) and that traffic actually reaches *our* iron-proxy. If the CA private key is stolen, or sandbox egress is redirected to attacker-controlled proxy infrastructure, an adversary-in-the-middle can present a valid leaf cert and the proxy tokens are no longer a meaningful boundary (cf. [MITRE ATT&CK T1588.004](https://attack.mitre.org/techniques/T1588/004/) — obtained TLS certificate material enabling AiTM). Protect the CA key (it's `0600`, host-only) and the proxy endpoint accordingly. -- 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 (AWS Bedrock SigV4, GCP Vertex service-account OAuth). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See [Uncovered providers](#uncovered-providers). -- iron-proxy in-memory secret zeroisation. The Go binary holds swapped-in real credentials in process memory; a core-dump or `/proc//mem` read from a same-uid attacker would expose them. Out of scope for this layer. - -## Failure modes - -- **Binary not installed, `auto_install: true`** — first `hermes egress setup` or `hermes egress start` downloads it. SHA-256 verified against the upstream `checksums.txt`. -- **Binary not installed, `auto_install: false`** — `start` fails with a clear message pointing to manual install. -- **`enabled: true` but proxy not running** — with `enforce_on_docker: true` (default), Docker sandbox creation refuses to start with an explanatory error. With `enforce_on_docker: false`, it falls back to direct outbound with real creds and logs a warning. -- **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. -- **`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. -- **BWS access token missing in `credential_source: bitwarden`** — `hermes egress start` refuses with `--no-bitwarden` as the recovery hint. -- **iron-proxy doesn't bind within 5 seconds** — process is killed, pidfile unlinked, error names the port + tail of `iron-proxy.log`. -- **Concurrent `hermes egress start` calls** — second call refuses with "another start in progress" if the first's daemon is up; otherwise the second unlinks the stale pidfile and proceeds. - -## Troubleshooting - -### "Refusing to start: BWS_ACCESS_TOKEN is not set" - -You enabled `credential_source: bitwarden` but the access-token env var isn't in your shell. Either: - -```bash -export BWS_ACCESS_TOKEN=… # one-shot -hermes egress start -``` - -Or move it into `~/.hermes/.env`. Or switch back to env mode: - -```bash -hermes egress setup --no-bitwarden -``` - -### "iron-proxy exited immediately" - -Look at the last 20 lines of `~/.hermes/proxy/iron-proxy.log`. Common causes: - -- Port already in use → change `proxy.tunnel_port` or kill whatever else owns 9090 -- Invalid `proxy.yaml` → run `hermes egress setup` to regenerate -- CA cert / key permissions wrong → `chmod 0o600 ~/.hermes/proxy/ca.key` - -### "iron-proxy did not bind \:9090 within 5s" - -The daemon started but never bound the listener. Usually means the binary is wedged or doing something expensive at startup. Check `~/.hermes/proxy/iron-proxy.log`. The orphan process is killed automatically and the pidfile cleaned up so you can just retry `hermes egress start`. - -### Sandbox times out connecting to the proxy (Linux) - -The container resolves `host.docker.internal` to the docker bridge gateway and the proxy is bound there, but a host firewall (commonly `ufw` with default-deny INPUT) drops container→host traffic on `docker0`. Verify from a container: - -```bash -docker run --rm --add-host host.docker.internal:host-gateway busybox \ - nc -zv -w 3 host.docker.internal 9090 -``` - -If that times out while `hermes egress status` shows `listening`, allow the bridge subnet in your firewall, e.g. for ufw: - -```bash -sudo ufw allow in on docker0 to any port 9090 proto tcp -sudo ufw allow in on docker0 to any port 9091 proto tcp -``` - -(9091 = the plain-HTTP forward listener on `tunnel_port + 1`.) - -### Sandbox sees `HTTP 403` from the proxy - -The agent inside the sandbox tried to hit a host that isn't in `proxy.extra_allowed_hosts`. The 403 body explains which host. If you want to allow it, add to your config: - -```yaml -proxy: - extra_allowed_hosts: - - api.example.com - - "*.staging.example.com" -``` - -Then `hermes egress setup` (to regenerate `proxy.yaml`) and `hermes egress stop && hermes egress start`. - -### Sandbox sees SSL verification errors - -Either the CA isn't mounted in the sandbox (rare; the docker backend does this automatically when `proxy.enabled: true`), or your image's HTTP client is reading from a non-standard env var. - -```bash -# Inside the sandbox: -cat /etc/ssl/certs/hermes-egress-ca.crt | head -1 -# Should print: -----BEGIN CERTIFICATE----- -env | grep -E "^(REQUESTS|CURL|SSL|NODE).*CA" -# Should list all four CA-bundle env vars pointing at /etc/ssl/certs/hermes-egress-ca.crt -``` - -If the cert isn't there, check that `proxy.enabled: true` AND `hermes egress status` shows `Listening yes`. If the env vars are missing, the sandbox image might be running an entrypoint that strips them — check your `docker_env` config. - -### Sandbox sees `HTTP 401` from upstreams - -Two common causes: - -1. **Token-clobber on re-setup.** You ran `hermes egress setup --rotate-tokens` (or rotated tokens some other way) and the running sandboxes still hold the old tokens. Restart the sandboxes. -2. **Bitwarden refresh failed silently.** Should not happen with the new fail-loud behavior, but if you have `proxy.allow_env_fallback: true` set, the daemon may have started with stale env values. Check the daemon's environment (`/proc//environ`) for the expected `OPENROUTER_API_KEY` etc. - -### "Address in use" after the parent process died - -The parent Hermes process died during `hermes egress start` (Ctrl-C during the listening probe, OOM, panic). The new fix-up logic writes the pidfile immediately after `Popen` so the orphan is recoverable: - -```bash -hermes egress stop # finds the orphan via the pidfile, kills it -hermes egress start -``` - -If `hermes egress stop` says "iron-proxy was not running" but you can still see the daemon in `ps`, the pidfile got out of sync. Manual recovery: - -```bash -pkill -TERM iron-proxy -rm -f ~/.hermes/proxy/iron-proxy.pid ~/.hermes/proxy/iron-proxy.nonce -hermes egress start -``` - -### Inspecting per-request behavior - -On the pinned binary version (**v0.39**) both daemon-level events and per-request records land in `~/.hermes/proxy/iron-proxy.log`. The format is line-delimited JSON. Grep for a specific upstream: - -```bash -grep '"upstream":"openrouter.ai"' ~/.hermes/proxy/iron-proxy.log | tail -20 -``` - -Or watch in real-time: - -```bash -tail -f ~/.hermes/proxy/iron-proxy.log | jq -``` - -When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-request records will move to `~/.hermes/proxy/audit.log` and `iron-proxy.log` will hold only daemon-level events. Until that bump, `audit.log` is an empty placeholder (pre-created at `0o600` so the future daemon inherits tight permissions) — wire your logrotate / monitoring tooling to `iron-proxy.log` today and plan to add `audit.log` after the version bump. - -## Limitations (v1) - -- Docker backend only. Modal, Daytona, and SSH wiring will follow in separate PRs. -- 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; restart (or `hermes egress reload` for ruleset-only changes) and restart already-running sandboxes after token rotation. -- iron-proxy in-memory secret zeroisation is upstream-controlled. Same-uid attackers with `/proc//mem` read access can read swapped-in secrets from the daemon's memory. -- iron-proxy v0.39 only supports a **single bind per daemon** (we bind the docker bridge gateway on Linux, loopback on Docker Desktop) and combines daemon + per-request records into a single log stream. When upstream adds `proxy.http_listens` (plural) and `log.audit_path`, a version bump can wire in multi-bind and the dedicated audit stream. - -## See also - -- Upstream project: [github.com/ironsh/iron-proxy](https://github.com/ironsh/iron-proxy) -- Upstream docs: [docs.iron.sh](https://docs.iron.sh/) -- Bitwarden integration: [`hermes secrets bitwarden`](../secrets/bitwarden) -- Hermes Docker terminal backend: [Docker](../docker) -- Developer / contributor reference: [Egress proxy internals](../../developer-guide/egress-internals) diff --git a/website/sidebars.ts b/website/sidebars.ts index cba9b6489b9..b012354a532 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -39,15 +39,6 @@ const sidebars: SidebarsConfig = { 'user-guide/secrets/bitwarden', ], }, - { - type: 'category', - label: 'Egress proxy', - collapsed: true, - items: [ - 'user-guide/egress/index', - 'user-guide/egress/iron-proxy', - ], - }, 'user-guide/sessions', 'user-guide/profiles', 'user-guide/profile-distributions', @@ -764,7 +755,6 @@ const sidebars: SidebarsConfig = { 'developer-guide/browser-supervisor', 'developer-guide/acp-internals', 'developer-guide/cron-internals', - 'developer-guide/egress-internals', 'developer-guide/trajectory-format', ], },