diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 43c3486806f9..b783f34db468 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -5491,19 +5491,32 @@ class APIServerAdapter(BasePlatformAdapter): try: from hermes_cli.auth import has_usable_secret - if not has_usable_secret(self._api_key, min_length=16): - logger.error( - "[%s] Refusing to start: API_SERVER_KEY is a " - "placeholder or too short (<16 chars). This endpoint " - "dispatches terminal-capable agent work — a guessable " - "key is remote code execution. Generate a strong secret " - "(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY " - "before starting the API server on %s.", - self.name, self._host, - ) - return False - except ImportError: - pass + except Exception as exc: + # Fail CLOSED. This guard is the only thing between a guessable + # key and a terminal-capable endpoint, so "the check could not be + # run" must not resolve to "start anyway" — the same posture + # tools/credential_files.py takes when its deny-list cannot be + # consulted. + logger.error( + "[%s] Refusing to start: API_SERVER_KEY strength could not be " + "verified (%s: %s), and this endpoint dispatches " + "terminal-capable agent work. Repair the installation before " + "starting the API server on %s.", + self.name, type(exc).__name__, exc, self._host, + ) + return False + + if not has_usable_secret(self._api_key, min_length=16): + logger.error( + "[%s] Refusing to start: API_SERVER_KEY is a " + "placeholder or too short (<16 chars). This endpoint " + "dispatches terminal-capable agent work — a guessable " + "key is remote code execution. Generate a strong secret " + "(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY " + "before starting the API server on %s.", + self.name, self._host, + ) + return False return True async def connect(self, *, is_reconnect: bool = False) -> bool: diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 53beee7fcd3e..9a5981923553 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -4787,3 +4787,66 @@ class TestSessionDbOffEventLoop: hermes_state.SessionDB = original_class auth_adapter._session_db = None auth_adapter._session_db_lock = None + + +# --------------------------------------------------------------------------- +# _api_key_passes_startup_guard — fail-closed on an unverifiable key +# --------------------------------------------------------------------------- + +class TestApiKeyStartupGuardFailsClosed: + """The guard is the only thing between a guessable key and an endpoint the + code itself describes as ``terminal-capable agent work`` where "a guessable + key is remote code execution". + + So "the strength check could not be run" must never resolve to "start + anyway" — the same posture ``tools/credential_files.py`` takes when its + deny-list cannot be consulted. + """ + + class _Stub: + name = "api_server" + _host = "0.0.0.0" + + def __init__(self, key): + self._api_key = key + + @staticmethod + def _guard(key): + return APIServerAdapter._api_key_passes_startup_guard( + TestApiKeyStartupGuardFailsClosed._Stub(key) + ) + + @staticmethod + def _blocking_auth_import(): + real_import = __import__ + + def _blocked(name, *args, **kwargs): + if name == "hermes_cli.auth": + raise ImportError("simulated: hermes_cli.auth unavailable") + return real_import(name, *args, **kwargs) + + return patch("builtins.__import__", _blocked) + + def test_weak_key_refused_when_check_is_unavailable(self): + """The bug: an unimportable auth module silently dropped the check and + the server started on a 4-character key.""" + with self._blocking_auth_import(): + assert self._guard("test") is False + + def test_strong_key_also_refused_when_check_is_unavailable(self): + """Fail-closed: we cannot verify the key, so we do not expose the + endpoint — the log tells the operator to repair the install.""" + with self._blocking_auth_import(): + assert self._guard("a" * 40) is False + + def test_strong_key_still_starts_normally(self): + """Control: the happy path is unchanged.""" + assert self._guard("a" * 40) is True + + def test_weak_key_still_refused_normally(self): + """Control: the original rejection is unchanged.""" + assert self._guard("test") is False + + def test_missing_key_still_refused(self): + """Control: the empty-key branch is unchanged.""" + assert self._guard("") is False