From af5b52647265a42d81bbf5f5f2880e6ca5c9313b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:30:08 -0700 Subject: [PATCH] fix(ssl): validate CA bundle paths before provider calls --- agent/ssl_guard.py | 116 +++++++++++++++---------------- gateway/run.py | 8 --- pyproject.toml | 1 + run_agent.py | 7 -- tests/agent/test_ssl_ca_guard.py | 80 ++++++++++----------- uv.lock | 8 ++- 6 files changed, 96 insertions(+), 124 deletions(-) diff --git a/agent/ssl_guard.py b/agent/ssl_guard.py index 85229f76dac..f477f0ce085 100644 --- a/agent/ssl_guard.py +++ b/agent/ssl_guard.py @@ -1,90 +1,84 @@ -"""Preventive SSL CA certificate guard for Hermes Agent. +"""Preventive SSL CA certificate checks for Hermes Agent. -This module provides an early fail-fast check to detect corrupted or missing -certifi CA bundles before any network client is initialized. +This module catches broken CA bundle paths before OpenAI/httpx turns them into +opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures. """ +from __future__ import annotations + import logging import os -import platform import ssl from pathlib import Path -import certifi - from agent.errors import SSLConfigurationError logger = logging.getLogger(__name__) +_CA_BUNDLE_ENV_VARS = ( + "HERMES_CA_BUNDLE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", +) + + +def _repair_hint() -> str: + return ( + "Repair: python -m pip install --force-reinstall certifi openai httpx\n" + "If you configured a custom corporate CA bundle, fix or unset the " + "broken CA bundle environment variable." + ) + def _ssl_err(message: str) -> SSLConfigurationError: - """Helper to create a consistent error with remediation hint.""" - return SSLConfigurationError(message + "\nRun: pip install -e .") + """Create a consistent, user-actionable SSL configuration error.""" + return SSLConfigurationError(f"{message}\n{_repair_hint()}") + + +def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None: + path = Path(value).expanduser() + if not path.exists(): + raise _ssl_err(f"{label} points to a missing CA bundle: {value}") + if not path.is_file(): + raise _ssl_err(f"{label} does not point to a CA bundle file: {value}") + if require_substantial and path.stat().st_size < 1024: + raise _ssl_err(f"{label} at {value} appears corrupted (too small)") + try: + ctx = ssl.create_default_context(cafile=str(path)) + except Exception as exc: + raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc + if not ctx.get_ca_certs(): + raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates") def verify_ca_bundle() -> None: - """Verify that the certifi CA bundle is valid and loadable. + """Verify configured and bundled CA certificates are present and loadable. Raises: - SSLConfigurationError: If the bundle is missing, empty, or cannot be - used to create a working SSLContext. + SSLConfigurationError: If an explicit CA-bundle environment variable + points at a bad path, or if certifi's bundled ``cacert.pem`` is + missing/corrupt. """ - if os.getenv("HERMES_SKIP_SSL_GUARD"): - logger.debug("SSL guard skipped via HERMES_SKIP_SSL_GUARD") - return - - ca_bundle = str(certifi.where()) - bundle_path = Path(ca_bundle) - - if not bundle_path.exists(): - raise _ssl_err(f"certifi CA bundle not found at {ca_bundle}") - - if bundle_path.stat().st_size < 1024: - raise _ssl_err(f"certifi CA bundle at {ca_bundle} appears corrupted (too small)") + for env_var in _CA_BUNDLE_ENV_VARS: + value = os.getenv(env_var) + if value: + _validate_bundle_path(env_var, value) try: - ctx = ssl.create_default_context(cafile=ca_bundle) + import certifi except Exception as exc: - raise _ssl_err( - f"CA certificate bundle at {ca_bundle} cannot be loaded: {exc}" - ) from exc + raise _ssl_err(f"certifi is not importable: {exc}") from exc - # Paranoid check + macOS fallback - if not ctx.get_ca_certs(): - try: - fallback = ssl.create_default_context() - if not fallback.get_ca_certs(): - raise _ssl_err( - f"CA certificate bundle at {ca_bundle} is empty and " - "no system CA certificates are available." - ) - logger.debug( - "certifi bundle at %s is empty but system CA store is ok", ca_bundle - ) - except Exception: - raise + ca_bundle = str(certifi.where()) + _validate_bundle_path("certifi", ca_bundle, require_substantial=True) def verify_ca_bundle_with_fallback() -> None: - """Verify CA bundle with macOS paranoid fallback. + """Backward-compatible wrapper for older call sites. - On macOS, if certifi fails but the system trust store works, - we allow startup (some corporate proxies / MDM setups break certifi). - The fallback only applies to "empty/unloadable" cases, not to - completely missing files. + The old PR name mentioned a platform fallback, but allowing startup with a + broken certifi bundle still leaves httpx/OpenAI and requests call sites + failing later. Keep the wrapper name but enforce the same check. """ - try: - verify_ca_bundle() - except SSLConfigurationError as e: - if platform.system() == "Darwin" and "not found" not in str(e).lower(): - try: - context = ssl.create_default_context() - if context.get_ca_certs(): - logger.warning( - "certifi bundle invalid but macOS system trust store works. " - "Proceeding with reduced security." - ) - return - except Exception: - pass - raise + verify_ca_bundle() diff --git a/gateway/run.py b/gateway/run.py index f0a6923c119..e57da6fd2a1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -30,14 +30,6 @@ import inspect import json import logging logger = logging.getLogger(__name__) - -# Early SSL certificate guard (after hermes_bootstrap) -try: - from agent.ssl_guard import verify_ca_bundle_with_fallback - verify_ca_bundle_with_fallback() -except Exception as e: - logger.warning(f"SSL guard failed: {e}") - import os import re import shlex diff --git a/pyproject.toml b/pyproject.toml index 5f645e12948..4a172bb1823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ # user picks that backend. Smaller `dependencies` = smaller blast # radius for the next supply-chain attack. "openai==2.24.0", + "certifi==2026.5.20", "python-dotenv==1.2.2", "fire==0.7.1", "httpx[socks]==0.28.1", diff --git a/run_agent.py b/run_agent.py index 5467b3b52a6..3ae34f7abf7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -34,13 +34,6 @@ except ModuleNotFoundError: import logging logger = logging.getLogger(__name__) -# Early SSL certificate guard (after hermes_bootstrap) -try: - from agent.ssl_guard import verify_ca_bundle_with_fallback - verify_ca_bundle_with_fallback() -except Exception as e: - logger.warning(f"SSL guard failed: {e}") - import asyncio import base64 import copy diff --git a/tests/agent/test_ssl_ca_guard.py b/tests/agent/test_ssl_ca_guard.py index bdbe74f2854..e2d91b76afc 100644 --- a/tests/agent/test_ssl_ca_guard.py +++ b/tests/agent/test_ssl_ca_guard.py @@ -1,82 +1,72 @@ """Tests for the preventive SSL CA bundle guard.""" -import os -import ssl from pathlib import Path -from unittest.mock import patch import certifi import pytest from agent.errors import SSLConfigurationError -from agent.ssl_guard import ( - verify_ca_bundle, - verify_ca_bundle_with_fallback, -) +from agent.ssl_guard import verify_ca_bundle, verify_ca_bundle_with_fallback -def test_healthy_bundle_passes(tmp_path, monkeypatch): +def test_healthy_bundle_passes(monkeypatch): """A real, non-empty certifi bundle must verify without raising.""" - # Sanity: certifi.where() must point to a real file in the test venv. + for key in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + monkeypatch.delenv(key, raising=False) bundle = Path(certifi.where()) assert bundle.exists() assert bundle.stat().st_size > 1024 - verify_ca_bundle() # should not raise + verify_ca_bundle() -def test_missing_bundle_raises_ssl_error(monkeypatch, tmp_path): +def test_missing_certifi_bundle_raises_ssl_error(monkeypatch, tmp_path): """Point certifi.where() at a non-existent path; expect a clear error.""" fake = tmp_path / "nope.pem" monkeypatch.setattr(certifi, "where", lambda: str(fake)) with pytest.raises(SSLConfigurationError) as exc: verify_ca_bundle() - assert "not found" in str(exc.value).lower() + message = str(exc.value).lower() + assert "certifi" in message + assert "missing" in message + assert "force-reinstall" in message -def test_empty_bundle_raises_ssl_error(monkeypatch, tmp_path): +def test_empty_certifi_bundle_raises_ssl_error(monkeypatch, tmp_path): """Empty file is treated as a corrupted bundle.""" fake = tmp_path / "empty.pem" fake.write_bytes(b"") monkeypatch.setattr(certifi, "where", lambda: str(fake)) with pytest.raises(SSLConfigurationError) as exc: verify_ca_bundle() - assert "corrupted" in str(exc.value).lower() or "empty" in str(exc.value).lower() + assert "too small" in str(exc.value).lower() -def test_skip_env_var_disables_guard(monkeypatch, tmp_path): - """HERMES_SKIP_SSL_GUARD=1 must make the guard a no-op.""" - monkeypatch.setenv("HERMES_SKIP_SSL_GUARD", "1") - fake = tmp_path / "nope.pem" # would raise if guard ran - monkeypatch.setattr(certifi, "where", lambda: str(fake)) - verify_ca_bundle() # should not raise +@pytest.mark.parametrize("env_var", ["HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_missing_explicit_ca_bundle_env_raises_before_httpx(monkeypatch, tmp_path, env_var): + """Bad CA-bundle env vars should be reported before OpenAI/httpx init.""" + fake = tmp_path / "missing.pem" + monkeypatch.setenv(env_var, str(fake)) + with pytest.raises(SSLConfigurationError) as exc: + verify_ca_bundle() + message = str(exc.value) + assert env_var in message + assert str(fake) in message + assert "force-reinstall" in message -def test_macos_fallback_allows_startup(monkeypatch, tmp_path): - """On Darwin, an unloadable certifi bundle must fall back to system trust. - - Only the fallback call (no cafile) is mocked — the certifi call must - fail naturally with SSLError from the broken PEM. The mock returns a - context with system CAs loaded, so the fallback succeeds. - """ +def test_invalid_explicit_ca_bundle_env_raises(monkeypatch, tmp_path): + """An existing but invalid explicit bundle should get a user-facing error.""" fake = tmp_path / "broken.pem" - # > 1024 bytes so the size guard doesn't short-circuit before ssl runs. - fake.write_bytes(b"not a real bundle" + b" " * 2000) - monkeypatch.setattr(certifi, "where", lambda: str(fake)) - monkeypatch.setattr("platform.system", lambda: "Darwin") + fake.write_text("not a cert bundle", encoding="utf-8") + monkeypatch.setenv("SSL_CERT_FILE", str(fake)) + with pytest.raises(SSLConfigurationError) as exc: + verify_ca_bundle() + assert "cannot be loaded" in str(exc.value) - _real_create = ssl.create_default_context - def _mock_create(purpose=ssl.Purpose.SERVER_AUTH, **kwargs): - if kwargs.get("cafile"): - # Let the certifi call hit the real SSL stack → raises SSLError - # on the broken PEM, which verify_ca_bundle() wraps as - # SSLConfigurationError. This is the path the fallback rescues. - return _real_create(purpose, **kwargs) - # Fallback call: simulate a healthy system trust store. - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.load_default_certs() - return ctx - - with patch("ssl.create_default_context", side_effect=_mock_create): - # Should NOT raise — macOS system trust store covers the broken bundle. +def test_verify_ca_bundle_with_fallback_keeps_same_contract(monkeypatch, tmp_path): + """The compatibility wrapper still rejects broken explicit CA paths.""" + fake = tmp_path / "missing.pem" + monkeypatch.setenv("SSL_CERT_FILE", str(fake)) + with pytest.raises(SSLConfigurationError): verify_ca_bundle_with_fallback() diff --git a/uv.lock b/uv.lock index d2786cc3754..5c51afc8fab 100644 --- a/uv.lock +++ b/uv.lock @@ -542,11 +542,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -1393,6 +1393,7 @@ name = "hermes-agent" version = "0.16.0" source = { editable = "." } dependencies = [ + { name = "certifi" }, { name = "croniter" }, { name = "fastapi" }, { name = "fire" }, @@ -1597,6 +1598,7 @@ requires-dist = [ { name = "azure-identity", marker = "extra == 'azure-identity'", specifier = "==1.25.3" }, { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, { name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" }, + { name = "certifi", specifier = "==2026.5.20" }, { name = "croniter", specifier = "==6.0.0" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" },