Files
hermes-agent/agent/proxy_sources/iron_proxy.py
Teknium 4d1880e0bf fix(integration): restore subprocess encoding/stdin guards dropped in simplification
Simplification workers collapsed subprocess call sites into shared kwargs
helpers and dropped the Windows/TUI safety kwargs on the way:

- encoding='utf-8', errors='replace' restored on text=True runs in
  copilot_acp_client, hermes_cli/setup (vercel install), managed_uv
  (codesign steps), local_runtime/hardware._stdout, a2a adapter.
- stdin=subprocess.DEVNULL restored on copilot probe, verify/runner
  _SUBPROCESS_KW, iron_proxy._run, google_meet playwright/system_profiler,
  simplex convert, whatsapp _RUN_TEXT, mem0 ollama serve Popen.
  google_meet sudo/brew install keeps inherited stdin (user-confirmed,
  may prompt) — marked noqa: subprocess-stdin.
- Windows-safe SIGKILL: getattr(signal, 'SIGKILL', SIGTERM) in
  verify/runner; photon _kill call re-marked windows-footgun: ok
  (unreachable on win32).
- scripts/check_subprocess_stdin.py now recognizes **kwargs splats
  (**_KW / **_kw(...)) ONLY when the same-file definition provably sets
  stdin= — covers tui_gateway _capture_run_kwargs/run_kw. Parity test added.
2026-09-02 16:36:10 -07:00

1415 lines
57 KiB
Python

"""iron-proxy (``ironsh/iron-proxy``) integration for credential-injecting egress control.
Sandboxes (Docker/Modal/SSH) hold only opaque proxy tokens; iron-proxy — a TLS-intercepting,
default-deny egress firewall — swaps them for real credentials on the way out, so a leaked
token is useless outside the trusted proxy boundary. The pinned binary is auto-installed
into ``<hermes_home>/bin``; CA, ``proxy.yaml``, ``mappings.json``, pidfile and logs live in
``<hermes_home>/proxy``. Failures warn and never block agent startup.
"""
from __future__ import annotations
import hashlib
import ipaddress
import json
import logging
import os
import platform
import shutil
import signal
import subprocess
import tarfile
import tempfile
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Pinned upstream version. Never auto-resolve "latest": the YAML schema may change between
# releases and updates must 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 + signing key for optional GPG verification of checksums.txt (SHA-256
# alone only protects the archive if checksums.txt itself came from an untampered channel).
_IRON_PROXY_CHECKSUM_SIG_NAME = "checksums.txt.asc"
_IRON_PROXY_PUBKEY_NAME = "public-key.asc"
_DOWNLOAD_TIMEOUT = 120 # binary is ~16MB
_RUN_TIMEOUT = 30
_STARTUP_GRACE_SECONDS = 5
# Management API (v0.39): authenticated loopback listener whose POST /v1/reload hot-swaps the
# ruleset. Bearer key minted at setup, stored 0600 at <proxy>/management.token, injected into
# the daemon env under this name; v0.39 refuses to start if the named var is empty.
_MGMT_API_KEY_ENV = "HERMES_IRON_PROXY_MGMT_KEY"
# tunnel_port is CONNECT/MITM, +1 is plain-HTTP forward, +2 is management.
_MGMT_PORT_OFFSET = 2
_MGMT_RELOAD_TIMEOUT = 15
# HTTPS_PROXY semantics use a single CONNECT tunnel, so only the tunnel listener is exposed.
_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 hosts on which the Authorization Bearer token is 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 authenticating with a non-Authorization header (v0.39 ``match_headers`` is
# case-insensitive). ``aliases`` are interchangeable env names for the SAME credential and
# MUST collapse into one mapping: every rule is ``require: true`` and two require-rules on one
# host would reject each other's requests. The sandbox gets the token under every name.
_HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = {
# Authorization also matched so an SDK sending the token as Bearer still swaps.
"ANTHROPIC_API_KEY": {
"hosts": ("api.anthropic.com",),
"match_headers": ("x-api-key", "Authorization"),
"aliases": (),
},
"AZURE_OPENAI_API_KEY": {
"hosts": ("*.openai.azure.com", "*.cognitiveservices.azure.com", "*.services.ai.azure.com"),
"match_headers": ("api-key", "Authorization"),
"aliases": (),
},
# ``?key=<token>`` SDK style is covered by match_query.
"GEMINI_API_KEY": {
"hosts": ("generativelanguage.googleapis.com",),
"match_headers": ("x-goog-api-key",),
"aliases": ("GOOGLE_API_KEY",),
},
}
# Recognized creds that cannot be swapped by static header replacement (SigV4 signing,
# SDK-minted OAuth). Surfaced as a warning only — they're generic cloud creds, never blocking.
_NON_BEARER_PROVIDERS: Tuple[str, ...] = (
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "GOOGLE_APPLICATION_CREDENTIALS",
)
# Default SSRF deny list for outbound traffic (docs promise: cloud metadata IPs refused
# regardless of allowlist). Callers may pass [] to disable (hermetic tests only).
_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
"::ffff:0:0/96", # IPv4-mapped IPv6 — else ::ffff:169.254.169.254 bypasses IMDS deny
"100.64.0.0/10", # RFC6598 CGNAT (AWS VPC shared services, k8s pod nets)
"198.18.0.0/15", # RFC2544 benchmark range
)
# Minimal env the iron-proxy subprocess needs; everything else is stripped so
# /proc/<pid>/environ never exposes the operator's shell secrets.
_PROXY_SUBPROCESS_ENV_ALLOWLIST: Tuple[str, ...] = (
"PATH", "HOME", "TMPDIR", "TZ", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR",
"SSL_CERT_DIR", "SSL_CERT_FILE",
"SYSTEMROOT", "USERPROFILE", # Windows
)
# Always stripped — these would recurse the proxy through itself or 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; SIGTERM there is TerminateProcess() — same semantics.
_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM)
# O_NOFOLLOW is POSIX-only; 0 is a no-op flag elsewhere.
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
# ``iron-proxy --version`` output keyed by binary path (get_status runs per container create).
_VERSION_CACHE: Dict[str, str] = {}
# Nonce planted in the daemon env at start so ``_pid_alive`` can prove a PID is still *our*
# binary 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
@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 bool(self.config_path and self.config_path.exists() and self.ca_cert_path 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 read from iron-proxy's OWN env at egress time; ``alias_env_names`` are extra names the
SANDBOX receives the same token under (not emitted in the proxy config)."""
proxy_token: str
real_env_name: str
upstream_hosts: Tuple[str, ...]
match_headers: Tuple[str, ...] = ("Authorization",)
alias_env_names: Tuple[str, ...] = ()
def _hermes_bin_dir() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "bin"
def _proxy_state_dir_ro() -> Path:
"""Proxy state dir without creating it (status probes, pidfile reads)."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "proxy"
def _proxy_state_dir() -> Path:
"""Proxy state dir, created 0o700 (holds the CA key, pidfile, logs); chmod is unconditional
so a pre-existing dir with a slack umask gets tightened."""
d = _proxy_state_dir_ro()
d.mkdir(parents=True, exist_ok=True)
try:
d.chmod(0o700)
except OSError:
pass # Windows no-op / shared fs we don't own; files still get explicit perms
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) -> ``iron-proxy_<version>_<os>_<arch>.tar.gz``; no Windows builds upstream."""
system = platform.system()
machine = platform.machine().lower()
os_name = {"Linux": "linux", "Darwin": "darwin"}.get(system)
if os_name:
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
return f"iron-proxy_{_IRON_PROXY_VERSION}_{os_name}_{arch}.tar.gz"
if system == "Windows":
raise RuntimeError(
f"iron-proxy does not ship native Windows binaries as of 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}")
def find_iron_proxy(*, install_if_missing: bool = False) -> Optional[Path]:
"""Managed ``<hermes_home>/bin`` copy first, then PATH; optionally auto-install."""
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 not install_if_missing:
return None
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
def install_iron_proxy(*, force: bool = False) -> Path:
"""Download, verify, and install the pinned binary; raises on any failure."""
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()
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", f"{_IRON_PROXY_RELEASE_BASE}/{asset_name}")
_release_asset(asset_name, archive_path)
_release_asset(_IRON_PROXY_CHECKSUM_NAME, checksum_path)
# Best-effort GPG check of checksums.txt closes the release-channel tamper gap.
_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}: 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 rejects escaping links; Python < 3.12 lacks the kwarg and
# relies on _pick_tar_member's path sanitization.
try:
tf.extract(member, tmp, filter="data") # noqa: S202
except TypeError:
tf.extract(member, tmp) # noqa: S202
extracted = tmp / member.name
# Stage then atomically rename so the 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, 0o755)
os.replace(staged, target)
# A freshly-installed binary must re-probe --version on the next get_status().
_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 _release_asset(name: str, dest: Path) -> None:
_http_download(f"{_IRON_PROXY_RELEASE_BASE}/{name}", dest)
def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool:
"""Best-effort GPG verification of checksums.txt in an ephemeral keyring. Returns False (with
a warning) when gpg or the signature assets are unavailable — SHA-256 stays enforced and gpg
is never a hard dependency. Raises ONLY on a present-but-bad signature (tamper signal)."""
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_path, pubkey_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME, tmp / _IRON_PROXY_PUBKEY_NAME
try:
_release_asset(_IRON_PROXY_CHECKSUM_SIG_NAME, sig_path)
_release_asset(_IRON_PROXY_PUBKEY_NAME, 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
gnupg_home = tmp / "gnupg"
gnupg_home.mkdir(mode=0o700, exist_ok=True)
gpg_base = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"]
imp = _run([*gpg_base, "--import", str(pubkey_path)], 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 = _run([*gpg_base, "--verify", str(sig_path), str(checksum_path)], timeout=60)
if verify.returncode != 0:
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 ``sha256sum`` output (``<hex> <filename>``)."""
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 in the archive (flat or one dir deep); reject abs paths and ``..``."""
candidates = [
m for m in tf.getmembers()
if m.isfile() and not m.name.startswith("/") and ".." not in Path(m.name).parts
and Path(m.name).name == binary_name
]
if not candidates:
raise RuntimeError(
f"Could not find {binary_name} inside downloaded archive "
f"(members: {[m.name for m in tf.getmembers()[:5]]}...)"
)
return min(candidates, key=lambda m: len(m.name))
def _allowlisted_env() -> Dict[str, str]:
"""Infrastructure-only env (PATH, HOME, locale) — never the operator's secrets."""
return {n: os.environ[n] for n in _PROXY_SUBPROCESS_ENV_ALLOWLIST if n in os.environ}
def _run(argv: List[str], *, timeout: int, text: bool = False, **kwargs) -> "subprocess.CompletedProcess":
"""``subprocess.run`` with captured output; argv[0] is always a trusted PATH/system binary."""
if text:
kwargs.update(text=True, encoding="utf-8", errors="replace")
return subprocess.run(argv, capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL, **kwargs) # noqa: S603
def iron_proxy_version(binary: Path) -> str:
"""``iron-proxy --version`` output, stripped and cached by path. Empty on failure."""
key = str(binary)
if key in _VERSION_CACHE:
return _VERSION_CACHE[key]
try:
# Scrubbed env: a PATH-resolved binary must not see the host's API keys.
res = _run([str(binary), "--version"], timeout=_RUN_TIMEOUT, text=True, env=_allowlisted_env())
except (OSError, subprocess.TimeoutExpired):
return ""
out = (res.stdout or res.stderr or "").strip()
if out: # never cache empty output — it would poison status for the process lifetime
_VERSION_CACHE[key] = out
return out
def _write_private_file(path: Path, data: bytes) -> None:
"""Create/truncate ``path`` 0o600 from the first byte (no chmod-after TOCTOU); O_NOFOLLOW
defends against a planted symlink; fchmod tightens a pre-existing file."""
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | _O_NOFOLLOW, 0o600)
try:
try:
os.fchmod(fd, 0o600)
except (OSError, AttributeError):
pass
os.write(fd, data)
finally:
os.close(fd)
def _fd_owned_by_us(fd: int) -> bool:
"""False iff the open file is owned by another uid (same threat model as the pidfile)."""
try:
st = os.fstat(fd)
return not (hasattr(os, "getuid") and st.st_uid != os.getuid())
except AttributeError:
return True # Windows
def ensure_ca_cert(*, force: bool = False) -> Tuple[Path, Path]:
"""Generate (or return existing) 10-year CA cert + key via the host ``openssl``."""
state = _proxy_state_dir()
ca_crt, ca_key = state / "ca.crt", 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."
)
with tempfile.TemporaryDirectory(prefix="hermes-proxy-ca-") as tmpdir:
tmp_key, tmp_crt = Path(tmpdir) / "ca.key", Path(tmpdir) / "ca.crt"
_run(["openssl", "genrsa", "-out", str(tmp_key), "4096"], timeout=60, check=True)
_run([
"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),
], timeout=60, check=True)
# Key: stage 0o600 against a fresh inode, then atomically rename into place.
key_staged = ca_key.with_suffix(ca_key.suffix + ".staged")
key_staged.unlink(missing_ok=True)
_write_private_file(key_staged, tmp_key.read_bytes())
os.replace(key_staged, ca_key)
# Cert is public — 0o644 matches typical PEM layout.
ca_crt.write_bytes(tmp_crt.read_bytes())
os.chmod(ca_crt, 0o644)
logger.info("Generated iron-proxy CA at %s", ca_crt)
return ca_crt, ca_key
def mint_proxy_token(prefix: str = "hermes-proxy") -> str:
"""Opaque token: recognizable prefix + 128-bit random hex suffix (iron-proxy matches exactly)."""
return f"{prefix}-{hashlib.sha256(os.urandom(32)).hexdigest()[:32]}"
def _read_text_or_none(p: Path) -> Optional[str]:
"""Stripped file contents, or None when missing/unreadable/empty."""
try:
return p.read_text(encoding="utf-8").strip() or None
except OSError:
return None
def ensure_management_token(*, force: bool = False) -> str:
"""Return the management-API bearer key (0600 at <proxy>/management.token), minting on first call."""
p = _proxy_state_dir() / "management.token"
existing = None if force else _read_text_or_none(p)
if existing:
return existing
token = mint_proxy_token(prefix="hermes-mgmt")
_write_private_file(p, token.encode("utf-8"))
return token
def _read_management_token() -> Optional[str]:
return _read_text_or_none(_proxy_state_dir_ro() / "management.token")
def _load_proxy_yaml(cfg: Path):
"""Parsed proxy.yaml, or ``{}`` when missing/unreadable/PyYAML absent."""
try:
import yaml
except ImportError:
return {}
try:
return yaml.safe_load(cfg.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return {}
def _parse_listen(listen) -> Optional[Tuple[str, int]]:
"""``"host:port"`` -> ``(host, port)``; empty host means loopback."""
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 _read_management_listen_from_config(config_path: Optional[Path] = None) -> Optional[Tuple[str, int]]:
"""``(host, port)`` of the management listener, if configured."""
data = _load_proxy_yaml(config_path or (_proxy_state_dir_ro() / "proxy.yaml"))
return _parse_listen((data.get("management") or {}).get("listen") or "")
def _read_http_listen_from_config() -> Optional[Tuple[str, int]]:
"""``(host, port)`` of the sandbox-facing listener: ``tunnel_listen`` (CONNECT/MITM), falling
back to ``http_listen`` for configs written before the listener-role split."""
proxy_block = _load_proxy_yaml(_proxy_state_dir_ro() / "proxy.yaml").get("proxy") or {}
return _parse_listen(proxy_block.get("tunnel_listen") or proxy_block.get("http_listen") or "")
def _probe_target() -> Tuple[str, int]:
"""Configured bind host/port to probe — on Linux that's the docker bridge, where a loopback
connect would report a healthy daemon as down."""
return _read_http_listen_from_config() or ("127.0.0.1", _DEFAULT_TUNNEL_PORT)
# Management-API error status -> operator message (422 = validation rejected, running ruleset
# unchanged; 401 = daemon started with a different management.token).
_RELOAD_HTTP_ERRORS = {
422: "iron-proxy rejected the new config (validation failed; the running ruleset is unchanged): {body}",
401: (
"management API rejected our key (401). The running "
"daemon was started with a different management.token — "
"run `hermes egress restart`."
),
}
def reload_proxy() -> bool:
"""Hot-reload the ruleset via ``POST /v1/reload`` (validation failures leave the running
config untouched). Raises RuntimeError with an actionable message on any failure."""
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`."
)
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:
try:
body = exc.read().decode("utf-8", errors="replace")[:500]
except OSError:
body = ""
message = _RELOAD_HTTP_ERRORS.get(exc.code, "management reload failed (HTTP {code}): {body}")
raise RuntimeError(message.format(code=exc.code, body=body)) from exc
except (urllib.error.URLError, OSError) as exc:
# A daemon started from a pre-management config is alive but has no listener.
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]:
"""Single bind (v0.39 allows one per daemon): docker bridge gateway on Linux — that's what
``host.docker.internal`` resolves to there, loopback is unreachable from containers — and
loopback on macOS/Windows Docker Desktop (VPNkit). NEVER 0.0.0.0: a LAN peer with a leaked
sandbox token could spend the operator's API quota."""
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]:
"""docker0 IPv4 via ``ip -4 addr show docker0``, or None. SECURITY: validated through
:mod:`ipaddress` so a hostile ``ip`` shim on PATH can't inject ``0.0.0.0`` (or
loopback/multicast/link-local/public) and reopen INADDR_ANY binding."""
try:
res = _run(["ip", "-4", "-o", "addr", "show", "docker0"], timeout=2, text=True)
except (OSError, subprocess.TimeoutExpired):
return None
if res.returncode != 0:
return None
# Expected: "<n>: docker0 inet 172.17.0.1/16 ..." — first inet token wins.
candidate = None
for line in res.stdout.splitlines():
parts = line.split()
hits = [parts[i + 1] for i, tok in enumerate(parts[:-1]) if tok == "inet"]
if hits:
candidate = hits[0].split("/")[0]
break
if not candidate:
return None
try:
addr = ipaddress.IPv4Address(candidate)
except (ipaddress.AddressValueError, ValueError):
return None
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 dict (schema as of v0.39.0). Real secrets are read from
iron-proxy's OWN env (``source: {type: env}``); the sandbox never sees them.
``upstream_deny_cidrs=None`` means the default SSRF deny list; ``[]`` opts out. ``audit_log``
is accepted for forward-compat but unused: v0.39's ``config.Log`` rejects ``audit_path``."""
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)
deny_cidrs = list(_DEFAULT_UPSTREAM_DENY_CIDRS if upstream_deny_cidrs is None else upstream_deny_cidrs)
# Per-provider headers (case-insensitive in v0.39); query scan covers ``?key=<token>`` SDKs;
# body inspection deliberately off. ``require`` fails closed: an allowlisted-host request
# WITHOUT the proxy token is rejected, so a real key sent directly can't cross the boundary.
secrets_rules = [
{
"source": {"type": "env", "var": m.real_env_name},
"replace": {
"proxy_value": m.proxy_token,
"match_headers": list(m.match_headers or ("Authorization",)),
"match_query": True,
"match_body": False,
"require": True,
},
"rules": [{"host": h} for h in m.upstream_hosts],
}
for m in mappings
]
# v0.39 takes ONE string per listener field (no plural form). tunnel_listen is the
# CONNECT+MITM listener sandboxes must reach via HTTPS_PROXY (a CONNECT to http_listen is
# forwarded upstream and 400s); http_listen is plain-HTTP forward on tunnel_port+1.
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"
return {
# Required by the parser; tunnel-only mode never binds an exposed DNS port.
"dns": {"listen": "127.0.0.1:0", "proxy_ip": "127.0.0.1"},
"proxy": {
# Both bind the docker bridge on Linux / loopback on Docker Desktop — NEVER 0.0.0.0.
"tunnel_listen": primary_listen,
"http_listen": f"{bind_host}:{tunnel_port + 1}",
# Direct-TLS listener is not exposed.
"https_listen": "127.0.0.1:0",
"max_request_body_bytes": 16 * 1024 * 1024,
"max_response_body_bytes": 0,
"upstream_response_header_timeout": "120s",
"upstream_deny_cidrs": deny_cidrs,
},
# v0.39 defaults its metrics server to :9090 — the same as our default tunnel_port —
# so pin it to an ephemeral loopback port (metrics effectively undiscoverable).
"metrics": {"listen": "127.0.0.1:0"},
# Loopback only: sandboxes must never reach the management surface.
"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": {"level": "info"},
}
def _open_private_append(path: Path, *, strict_chmod: bool) -> int:
"""Open ``path`` O_APPEND|O_CREAT 0o600 with O_NOFOLLOW (planted symlinks refused) and tighten
a pre-existing file via fchmod (its failure is fatal iff ``strict_chmod``). Raises OSError;
caller owns the fd."""
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND | _O_NOFOLLOW, 0o600)
try:
os.fchmod(fd, 0o600)
except OSError:
if strict_chmod:
os.close(fd)
raise
return fd
def ensure_audit_log(audit_path: Path) -> None:
"""Pre-create the audit log 0o600 (forward-compat: v0.39 never writes it, no ``log.audit_path``).
Raises RuntimeError on any OSError (planted symlink, immutable dir, full disk)."""
try:
os.close(_open_private_append(audit_path, strict_chmod=True))
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_state_file_atomic(state: Path, name: str, dump) -> Path:
"""Write ``<state>/<name>`` via a 0600 temp file + atomic replace: the file holds proxy
token values, and chmod-after-replace would leave a world-readable TOCTOU window."""
tmp_path = state / f".{name}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
dump(f)
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, state / name)
return state / name
def write_proxy_config(config: Dict) -> Path:
"""Serialize the config dict to ``<hermes_home>/proxy/proxy.yaml`` (safe_dump, no 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
return _write_state_file_atomic(
_proxy_state_dir(), "proxy.yaml",
lambda f: yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False),
)
def write_mappings(mappings: List[TokenMapping]) -> Path:
"""Persist sandbox-visible tokens to ``mappings.json`` (read by the Docker backend, not iron-proxy)."""
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
],
}
return _write_state_file_atomic(_proxy_state_dir(), "mappings.json", lambda f: json.dump(payload, f, indent=2))
def load_mappings() -> List[TokenMapping]:
"""Read mappings.json, if it exists. Empty list on any error."""
f = _proxy_state_dir() / "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 files load with the bearer defaults they were written under.
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 _env_names(available_env_names: Optional[List[str]]) -> set:
"""Explicit override (Bitwarden adapter) or the non-empty names in the host env."""
if available_env_names is not None:
return set(available_env_names)
return {k for k, v in os.environ.items() if v}
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 (bearer providers first).
Canonical OR any alias present -> ONE mapping keyed on the canonical name (the subprocess-env
builder mirrors an alias value into it)."""
names = _env_names(available_env_names)
specs = [(n, h, ("Authorization",), ()) for n, h in _BEARER_PROVIDERS.items()] + [
(n, tuple(s["hosts"]), tuple(s["match_headers"]), tuple(s.get("aliases") or ()))
for n, s in _HEADER_AUTH_PROVIDERS.items()
]
return [
TokenMapping(
proxy_token=mint_proxy_token(prefix=env_name.lower().replace("_api_key", "")),
real_env_name=env_name,
upstream_hosts=hosts,
match_headers=headers,
alias_env_names=aliases,
)
for env_name, hosts, headers, aliases in specs
if env_name in names or any(a in names for a in aliases)
]
def discover_uncovered_providers(*, available_env_names: Optional[List[str]] = None) -> List[str]:
"""Env names of recognized providers the proxy can't swap (SigV4 / SDK-minted OAuth)."""
names = _env_names(available_env_names)
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 existing and freshly discovered mappings. Tokens already in ``existing`` are
preserved (containers baked with them keep working) while hosts/headers/aliases are refreshed
from ``discovered``; ``rotate=True`` re-mints everything; undiscovered providers are dropped."""
by_name = {} if rotate else {m.real_env_name: m for m in existing}
return [
replace(d, proxy_token=by_name[d.real_env_name].proxy_token) if d.real_env_name in by_name else d
for d in discovered
]
def _pidfile() -> Path:
return _proxy_state_dir() / "iron-proxy.pid"
def _read_pid() -> Optional[int]:
try:
pid = int((_proxy_state_dir_ro() / "iron-proxy.pid").read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
return pid if pid > 0 else None
def _pid_proc_starttime(pid: int) -> Optional[str]:
"""/proc/<pid>/stat starttime (field 22) on Linux, else None — cheap PID-recycling detector."""
try:
text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except OSError:
return None
# comm may contain spaces/parens, so split after the LAST ")"; field 22 -> tail index 19.
rparen = text.rfind(")")
fields = text[rparen + 1:].split() if rparen >= 0 else []
return fields[19] if len(fields) > 19 else None
def _persisted_nonce_path() -> Path:
"""On-disk nonce sibling of the pidfile, so stop/status in a later CLI process can still
defeat PID recycling."""
return _proxy_state_dir_ro() / "iron-proxy.nonce"
def _read_persisted_nonce() -> Optional[str]:
"""Nonce from disk, or None if missing/unreadable/empty/not owned by us (callers fall back
to argv0 matching). O_NOFOLLOW: this read decides whether stop_proxy SIGKILLs a PID."""
try:
fd = os.open(str(_persisted_nonce_path()), os.O_RDONLY | _O_NOFOLLOW)
except OSError:
return None
try:
if not _fd_owned_by_us(fd):
return None
return os.read(fd, 256).decode("utf-8", errors="ignore").strip() or None
finally:
os.close(fd)
def _pid_alive(pid: int) -> bool:
"""True iff ``pid`` is alive AND is an iron-proxy process. PID-reuse defense, in priority
order: nonce in /proc/<pid>/environ, argv[0] basename in /proc/<pid>/cmdline, ``ps -o comm=``
basename. A loose ``"iron-proxy" in cmdline`` match would hit ``tail iron-proxy.log``."""
if pid <= 0:
return False
try:
# psutil when available: os.kill(pid, 0) on Windows is a HARD kill (bpo-14484).
import psutil # type: ignore
if not psutil.pid_exists(pid):
return False
except ImportError:
if platform.system() != "Windows":
try:
os.kill(pid, 0) # windows-footgun: ok — POSIX-only branch
except (ProcessLookupError, PermissionError, OSError):
return False
# Strong proof: nonce from this process's start_proxy and/or the on-disk sibling file.
nonce_candidates = [n for n in dict.fromkeys((_proxy_nonce, _read_persisted_nonce())) if n]
if nonce_candidates:
try:
env_bytes = Path(f"/proc/{pid}/environ").read_bytes()
if any(f"{_HERMES_IRON_PROXY_NONCE_ENV}={n}".encode() in env_bytes for n in nonce_candidates):
return True
except OSError:
pass
try:
cmdline_path = Path(f"/proc/{pid}/cmdline")
if cmdline_path.exists():
argv0 = cmdline_path.read_bytes().split(b"\x00")[0].decode("utf-8", errors="ignore")
return os.path.basename(argv0).startswith("iron-proxy")
except OSError:
pass
# macOS / non-Linux fallback.
try:
res = _run(["ps", "-p", str(pid), "-o", "comm="], timeout=2, text=True)
if res.returncode == 0:
return os.path.basename((res.stdout or "").strip()).startswith("iron-proxy")
except (OSError, subprocess.TimeoutExpired):
pass
# Exotic platforms: if the OS says alive, believe it.
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 already running).
``refresh_secrets_from_bitwarden=True`` re-fetches upstream secrets from BWS at startup —
the rotation promise of ``credential_source: bitwarden``."""
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.")
# Minimal env: os.environ.copy() would expose every operator secret via /proc/<pid>/environ.
env = _build_proxy_subprocess_env(
extra_env=extra_env,
refresh_from_bitwarden=refresh_secrets_from_bitwarden,
bitwarden_config=bitwarden_config,
)
# v0.39 validates api_key_env is non-empty when management.listen is set.
if _read_management_listen_from_config(cfg) is not None:
env[_MGMT_API_KEY_ENV] = ensure_management_token()
# Per-start nonce for PID-recycling defense; module-global is fine (one proxy per 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"
proc = _spawn_daemon(bin_path, cfg, env, log_path)
# Pidfile BEFORE the listening poll: if the parent dies mid-poll, `hermes egress stop`
# can still clean up the orphan.
pidfile = _pidfile()
try:
_write_pidfile_safely(pidfile, proc.pid)
except RuntimeError:
_kill_and_wait(proc, grace_seconds=2)
raise
def _abort(msg: str, *, kill: bool) -> RuntimeError:
tail = _tail_log(log_path, lines=20)
if kill:
_kill_and_wait(proc, grace_seconds=2)
pidfile.unlink(missing_ok=True)
return RuntimeError(f"{msg}Last log lines:\n{tail}")
def _exited_error() -> RuntimeError:
return _abort(f"iron-proxy exited immediately (code {proc.returncode}). ", kill=False)
def _interrupt_handler(_signum, _frame): # pragma: no cover - signal path
# Ctrl-C while waiting must not leak an orphan holding the port.
_kill_and_wait(proc, grace_seconds=2)
pidfile.unlink(missing_ok=True)
raise KeyboardInterrupt()
# Poll with timeout (the Go binary is up in <200ms). Probe the CONFIGURED bind host — on
# Linux that's the docker bridge, where loopback never connects.
probe_host, tunnel_port = _probe_target()
install_handlers = platform.system() != "Windows" and threading.current_thread() is threading.main_thread()
prev_sigint = prev_sigterm = None
if install_handlers:
prev_sigint = signal.signal(signal.SIGINT, _interrupt_handler)
prev_sigterm = signal.signal(signal.SIGTERM, _interrupt_handler)
try:
listening = _await_listening(proc, probe_host, tunnel_port, on_exit=_exited_error)
finally:
if install_handlers:
signal.signal(signal.SIGINT, prev_sigint)
signal.signal(signal.SIGTERM, prev_sigterm)
# Process may have died right at deadline.
if proc.poll() is not None:
raise _exited_error()
# Alive-but-not-listening is a failure: an orphan holding the port breaks every restart.
if not listening:
raise _abort(
f"iron-proxy did not bind {probe_host}:{tunnel_port} within "
f"{_STARTUP_GRACE_SECONDS}s. Process was killed. ",
kill=True,
)
logger.info("Started iron-proxy pid=%s config=%s", proc.pid, cfg)
return get_status()
def _spawn_daemon(bin_path: Path, cfg: Path, env: Dict[str, str], log_path: Path) -> "subprocess.Popen":
"""Popen the daemon with stdout/stderr appended to ``log_path`` (0o600 from the first byte,
O_NOFOLLOW so a planted symlink e.g. to authorized_keys can't receive output, owner-checked).
Our log fd is closed right after Popen — the child has its dup."""
try:
log_fd = _open_private_append(log_path, strict_chmod=False)
except OSError as exc:
raise RuntimeError(
f"Refusing to write iron-proxy log {log_path}: {exc}. Remove that path manually and retry."
) from exc
if not _fd_owned_by_us(log_fd):
st = os.fstat(log_fd)
os.close(log_fd)
raise RuntimeError(f"iron-proxy log {log_path} has unexpected owner uid={st.st_uid}; refusing to write.")
try:
# start_new_session is POSIX-only (Windows isn't supported anyway — no upstream binary).
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
return subprocess.Popen([str(bin_path), "-config", str(cfg)], **popen_kwargs) # noqa: S603
except OSError as exc:
raise RuntimeError(f"failed to spawn iron-proxy: {exc}") from exc
finally:
try:
os.close(log_fd)
except OSError:
pass
def _await_listening(proc: "subprocess.Popen", host: str, port: int, *, on_exit) -> bool:
"""Poll until ``host:port`` accepts or the grace window lapses (do-while: checks at least
once even with a 0s window). Raises ``on_exit()`` if the child dies meanwhile."""
deadline = time.time() + _STARTUP_GRACE_SECONDS
while True:
if proc.poll() is not None:
raise on_exit()
if _port_listening(host, port):
return True
if time.time() >= deadline:
return False
time.sleep(0.1)
def _write_pidfile_safely(pidfile: Path, pid: int) -> None:
"""Write the pidfile with O_EXCL + O_NOFOLLOW + ownership check, then persist the nonce.
O_EXCL on an existing pidfile means either a concurrent start (fail cleanly) or a stale
crash leftover (unlink and retry once)."""
open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW
try:
fd = os.open(str(pidfile), open_flags, 0o600)
except FileExistsError:
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."
)
pidfile.unlink(missing_ok=True)
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:
if not _fd_owned_by_us(fd):
raise RuntimeError(f"pidfile {pidfile} has unexpected owner uid={os.fstat(fd).st_uid}")
os.write(fd, str(pid).encode("utf-8"))
finally:
os.close(fd)
# Best-effort nonce sibling (0o600); without it stop falls back to argv0 matching.
if _proxy_nonce:
try:
_write_private_file(pidfile.with_suffix(".nonce"), _proxy_nonce.encode("utf-8"))
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]:
"""Minimal env for the daemon: allowlisted infra vars + the secrets named in mappings. With
``refresh_from_bitwarden`` and a populated ``bitwarden_config``, secrets are fetched from BWS
at startup (the rotation guarantee); without ``allow_env_fallback`` any BWS shortfall fails
closed rather than silently keeping stale host-env values."""
env = _allowlisted_env()
parent = os.environ
# Forward ONLY the secrets named in mappings. For alias providers the rule is keyed on the
# canonical name; mirror the alias value into it when only the alias is set.
mappings = load_mappings()
needed = {m.real_env_name for m in mappings}
alias_sources = {m.real_env_name: m.alias_env_names for m in mappings if 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
if refresh_from_bitwarden and bitwarden_config:
_refresh_secrets_from_bitwarden(
env, needed, bitwarden_config, bool(bitwarden_config.get("allow_env_fallback")),
)
# Caller overrides win (wizard test secrets), then strip proxy-recursion vars regardless.
if extra_env:
env.update(extra_env)
for name in _PROXY_SUBPROCESS_ENV_STRIP:
env.pop(name, None)
env.setdefault("NO_COLOR", "1")
return env
def _bitwarden_shortfall(allow_env_fallback: bool, error: str, warning: str, *args) -> None:
"""Fail closed with ``error`` unless the operator opted into the legacy host-env fallback,
in which case only ``warning`` (``%``-formatted with ``args``) is logged."""
if not allow_env_fallback:
raise RuntimeError(error)
logger.warning(warning, *args)
def _refresh_secrets_from_bitwarden(
env: Dict[str, str], needed: set, bitwarden_config: Dict, allow_env_fallback: bool,
) -> None:
"""Overwrite ``env[needed]`` with fresh BWS values (uncached fetch). Only mapped names are
injected so unrelated BWS secrets never leak into the daemon env."""
try:
# Lazy: the bitwarden module isn't importable in every install.
from agent.secret_sources import bitwarden as bw
access_token = os.environ.get(bitwarden_config.get("access_token_env", "BWS_ACCESS_TOKEN"), "").strip()
project_id = bitwarden_config.get("project_id", "")
if not (access_token and project_id):
# Don't interpolate access_token_name — CodeQL treats config values as tainted.
_bitwarden_shortfall(
allow_env_fallback,
"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.",
"credential_source=bitwarden but access-token env or "
"project_id is empty — proxy will fall back to parent env "
"(allow_env_fallback=true).",
)
return
secrets, warnings = bw.fetch_bitwarden_secrets(
access_token=access_token,
project_id=project_id,
cache_ttl_seconds=0,
use_cache=False,
)
except ImportError as exc:
# A dependency vanishing between setup and restart must not silently degrade.
if not 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,
)
return
missing = sorted(needed - set(secrets))
for n in needed:
if n in secrets:
env[n] = secrets[n]
if missing:
_bitwarden_shortfall(
allow_env_fallback,
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.",
"Bitwarden refresh did not return secrets for %s — "
"falling back to host env for those names "
"(allow_env_fallback=true).",
missing,
)
# Log only the count: the taint analyzer can't tell bws status text is non-secret.
if warnings:
logger.warning(
"Bitwarden refresh produced %d warning(s); "
"run `hermes secrets bitwarden status` for detail.",
len(warnings),
)
def _forget_daemon() -> None:
"""Best-effort removal of pidfile + persisted nonce, and drop the in-process nonce."""
global _proxy_nonce
_pidfile().unlink(missing_ok=True)
try:
_persisted_nonce_path().unlink()
except OSError:
pass
_proxy_nonce = None
def stop_proxy() -> bool:
"""Stop the managed iron-proxy. Returns True if it was running."""
pid = _read_pid()
if not pid or not _pid_alive(pid):
_forget_daemon()
return False
# Capture starttime BEFORE signalling: if the pid is recycled mid-wait, abort the SIGKILL.
starttime_before = _pid_proc_starttime(pid)
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
_forget_daemon()
return False
# Up to 5s for graceful exit, then SIGKILL — unless the pid was recycled meanwhile.
deadline = time.time() + 5.0
while time.time() < deadline:
if not _pid_alive(pid):
break
time.sleep(0.1)
else:
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
_forget_daemon()
logger.info("Stopped iron-proxy pid=%s", pid)
return True
def get_status() -> ProxyStatus:
"""Snapshot the proxy state without side effects (called per Docker container create)."""
status = ProxyStatus()
probe_host, status.tunnel_port = _probe_target()
binary = find_iron_proxy(install_if_missing=False)
if binary:
status.binary_path = binary
status.binary_version = iron_proxy_version(binary)
state = _proxy_state_dir_ro()
cfg, ca = state / "proxy.yaml", state / "ca.crt"
status.config_path = cfg if cfg.exists() else None
status.ca_cert_path = ca if ca.exists() else None
pid = _read_pid()
if pid and _pid_alive(pid):
status.pid = pid
status.listening = _port_listening(probe_host, status.tunnel_port)
return status
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})"
def _reset_for_tests() -> None:
"""Clear the module's mutable globals (``_VERSION_CACHE``, ``_proxy_nonce``)."""
global _proxy_nonce
_VERSION_CACHE.clear()
_proxy_nonce = None
__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",
]