diff --git a/cron/scheduler.py b/cron/scheduler.py index 6580427a6e..6c86f9f2f8 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -5509,6 +5509,24 @@ def run_job( job_id = job["id"] job_name = str(job.get("name") or job.get("prompt") or job_id or "cron job") + # Fail closed on a corrupt config.yaml before any agent-driven work + # (issue #81952): a cron fire is fully non-interactive, and continuing + # on built-in defaults lets provider auto-detection adopt .env + # credentials the config never named, billing a provider the user did + # not choose. no_agent script jobs are exempt — they never construct an + # AIAgent or spend tokens. Escape hatch: HERMES_IGNORE_USER_CONFIG=1. + if not job.get("no_agent"): + from hermes_cli.config import ( + InvalidUserConfigError, + require_parseable_user_config, + ) + + try: + require_parseable_user_config() + except InvalidUserConfigError as exc: + logger.error("Job '%s': refusing to run — %s", job_id, exc) + return (False, f"# Cron Job: {job_name}\n\nError: {exc}\n", "", str(exc)) + # --------------------------------------------------------------- # no_agent short-circuit — the script IS the job, no LLM involvement. # --------------------------------------------------------------- diff --git a/gateway/run.py b/gateway/run.py index 80cf7e2f4b..0eaf7bea64 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -33429,8 +33429,34 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = return True +def _guard_corrupt_user_config() -> None: + """Fail closed when the active profile's config.yaml cannot be parsed. + + The gateway is a fully non-interactive surface: nobody is present to + repair a corrupt ``config.yaml``, and silently continuing on built-in + defaults lets provider auto-detection adopt credentials from ``.env`` + that the config never named (issue #81952). Same policy and escape + hatch (``HERMES_IGNORE_USER_CONFIG=1``) as the non-interactive CLI + guard in ``hermes_cli/main.py``. + """ + from hermes_cli.config import ( + InvalidUserConfigError, + require_parseable_user_config, + ) + + try: + require_parseable_user_config() + except InvalidUserConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + + def main(): """CLI entry point for the gateway.""" + # Refuse to start on a corrupt config.yaml — before any config-dependent + # startup (watchdog, DB opens, provider resolution). See _guard docstring. + _guard_corrupt_user_config() + # Advertise the agent harness to child processes (AI_AGENT is the # cross-agent standard; HERMES_AGENT the Hermes-specific marker — see # _advertise_agent_env in hermes_cli/main.py, kept inline here to avoid diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c6cd109618..e9af95edd2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12103,6 +12103,23 @@ def cmd_dashboard(args): # ready sentinel. Resolved once and threaded through the re-exec, the # build gate, and start_server. _headless_backend = getattr(args, "headless_backend", False) + # `hermes serve` is headless/non-interactive: fail closed on a corrupt + # config.yaml instead of silently starting on defaults where provider + # auto-detection can adopt unnamed .env credentials (issue #81952). + # Same policy + escape hatch as _guard_noninteractive_user_config. + if _headless_backend: + from hermes_cli.config import ( + InvalidUserConfigError, + require_parseable_user_config, + ) + + try: + require_parseable_user_config( + ignore_user_config=bool(getattr(args, "ignore_user_config", False)) + ) + except InvalidUserConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc _ssh_owner_nonce = getattr(args, "ssh_owner_nonce", None) if _ssh_owner_nonce and not re.fullmatch(r"[0-9a-f]{16}", _ssh_owner_nonce): raise SystemExit("--ssh-owner-nonce must be 16 lowercase hex characters") diff --git a/tests/hermes_cli/test_config_guard_surfaces.py b/tests/hermes_cli/test_config_guard_surfaces.py new file mode 100644 index 0000000000..8721f01a82 --- /dev/null +++ b/tests/hermes_cli/test_config_guard_surfaces.py @@ -0,0 +1,155 @@ +"""Fail-closed corrupt-config guards on gateway, serve, and cron surfaces. + +Companion to test_noninteractive_config_guard.py (PR #81988): issue #81952 +extended to every non-interactive startup surface. +""" + +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated_config_env(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False) + yield + os.environ.pop("HERMES_IGNORE_USER_CONFIG", None) + + +def _write_corrupt_config(tmp_path): + path = tmp_path / "config.yaml" + path.write_text("model: [unterminated\n", encoding="utf-8") + return path + + +class TestGatewayGuard: + def test_gateway_refuses_corrupt_config(self, tmp_path, capsys): + from gateway.run import _guard_corrupt_user_config + + _write_corrupt_config(tmp_path) + + with pytest.raises(SystemExit) as exc_info: + _guard_corrupt_user_config() + + assert exc_info.value.code == 2 + assert "Refusing non-interactive startup" in capsys.readouterr().err + + def test_gateway_allows_valid_config(self, tmp_path): + from gateway.run import _guard_corrupt_user_config + + (tmp_path / "config.yaml").write_text("model:\n default: local/test\n") + _guard_corrupt_user_config() # must not raise + + def test_gateway_allows_missing_config(self, tmp_path): + from gateway.run import _guard_corrupt_user_config + + _guard_corrupt_user_config() # first-run state: must not raise + + def test_gateway_escape_hatch(self, monkeypatch, tmp_path): + from gateway.run import _guard_corrupt_user_config + + _write_corrupt_config(tmp_path) + monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1") + _guard_corrupt_user_config() # must not raise + + +class TestCronRunJobGuard: + def _job(self, **overrides): + job = {"id": "job-test-1", "name": "guard test", "prompt": "hi"} + job.update(overrides) + return job + + def test_run_job_fails_closed_on_corrupt_config(self, tmp_path): + from cron.scheduler import run_job + + _write_corrupt_config(tmp_path) + + success, output_doc, final_response, error = run_job(self._job()) + + assert success is False + assert error is not None + assert "Refusing non-interactive startup" in error + assert "config.yaml" in error + assert final_response == "" + + def test_run_job_escape_hatch(self, monkeypatch, tmp_path): + from cron.scheduler import run_job + + _write_corrupt_config(tmp_path) + monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1") + + # With the escape hatch active the guard must not trip. The job then + # proceeds into normal execution; a missing provider/model in the + # empty temp HERMES_HOME may fail later, but never with the guard's + # refusal message. + success, output_doc, final_response, error = run_job( + self._job(no_agent=True, script="true", deliver="none") + ) + assert "Refusing non-interactive startup" not in (error or "") + + def test_run_job_no_agent_exempt(self, tmp_path): + from cron.scheduler import run_job + + _write_corrupt_config(tmp_path) + + success, output_doc, final_response, error = run_job( + self._job(no_agent=True, script="true", deliver="none") + ) + assert "Refusing non-interactive startup" not in (error or "") + + +class TestServeGuard: + def test_serve_headless_refuses_corrupt_config(self, tmp_path, capsys): + """The `hermes serve` headless path fails closed before startup.""" + from argparse import Namespace + + from hermes_cli import main as main_mod + + _write_corrupt_config(tmp_path) + args = Namespace( + headless_backend=True, + ignore_user_config=False, + ssh_session_token_file=None, + ssh_owner_nonce=None, + status=False, + stop=False, + ) + + with pytest.raises(SystemExit) as exc_info: + main_mod.cmd_dashboard(args) + + assert exc_info.value.code == 2 + assert "Refusing non-interactive startup" in capsys.readouterr().err + + def test_serve_escape_hatch_passes_guard(self, tmp_path, monkeypatch): + """--ignore-user-config lets serve get past the corrupt-config guard.""" + from argparse import Namespace + + from hermes_cli import main as main_mod + + _write_corrupt_config(tmp_path) + args = Namespace( + headless_backend=True, + ignore_user_config=True, + ssh_session_token_file=None, + ssh_owner_nonce=None, + status=False, + stop=False, + ) + + # Stop execution right after the guard: the next thing cmd_dashboard + # touches on the headless path is the nonce regex via `re`. + sentinel = RuntimeError("passed-guard") + + class _ReStop: + def fullmatch(self, *a, **k): + raise sentinel + + monkeypatch.setattr(main_mod, "re", _ReStop()) + args.ssh_owner_nonce = "0123456789abcdef" + + with pytest.raises(RuntimeError, match="passed-guard"): + main_mod.cmd_dashboard(args)