diff --git a/scripts/release.py b/scripts/release.py index c5ceac0a9f..bb943595ab 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -905,6 +905,7 @@ AUTHOR_MAP = { "montbra@gmail.com": "Montbra", # PR #20897 salvage of #16189 (TUI voice PTT) "promptsiren@gmail.com": "firefly", # PR #18123 salvage of #16660 (ContextVars) "wtyopenclaw@gmail.com": "WuTianyi123", # PR #20275 salvage of #13723 (feishu markdown) + "zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events) # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan } diff --git a/tests/tools/test_cron_approval_mode.py b/tests/tools/test_cron_approval_mode.py index abd730ca3a..3826813157 100644 --- a/tests/tools/test_cron_approval_mode.py +++ b/tests/tools/test_cron_approval_mode.py @@ -256,3 +256,77 @@ class TestCronModeInteractions: result = check_dangerous_command("rm -rf /tmp/stuff", "local") assert result["approved"] + + +class TestCronWithGatewayOrigin: + """Cron jobs originating from a gateway platform must NOT be treated as gateway. + + cron/scheduler.py binds HERMES_SESSION_PLATFORM via contextvars for + delivery routing (so cron output lands back in the origin chat). The + API-server approvals work (PR #20311) made check_dangerous_command treat + any contextvar-bound platform as a gateway session. That would route + cron-from-telegram/discord/etc. through submit_pending with no listener, + hanging the job instead of respecting approvals.cron_mode. + """ + + def test_cron_with_telegram_origin_uses_cron_mode_not_gateway(self, monkeypatch): + """Cron + contextvar platform=telegram + cron_mode=deny → BLOCKED, not pending.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + + from gateway.session_context import set_session_vars, clear_session_vars + tokens = set_session_vars(platform="telegram", chat_id="123") + try: + from unittest.mock import patch as mock_patch + with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"): + result = check_dangerous_command("rm -rf /tmp/stuff", "local") + # Cron-mode path: BLOCKED message, NOT pending/approval_required. + assert not result["approved"] + assert "BLOCKED" in result["message"] + assert "cron_mode" in result["message"] + assert result.get("status") != "approval_required" + finally: + clear_session_vars(tokens) + + def test_cron_with_telegram_origin_approve_mode_allows(self, monkeypatch): + """Cron + contextvar platform=telegram + cron_mode=approve → allowed via cron path.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + + from gateway.session_context import set_session_vars, clear_session_vars + tokens = set_session_vars(platform="discord", chat_id="456") + try: + from unittest.mock import patch as mock_patch + with mock_patch("tools.approval._get_cron_approval_mode", return_value="approve"): + result = check_dangerous_command("rm -rf /tmp/stuff", "local") + assert result["approved"] + # Should NOT be a gateway-approval response. + assert result.get("status") != "approval_required" + finally: + clear_session_vars(tokens) + + def test_cron_with_telegram_origin_combined_guard_uses_cron_mode(self, monkeypatch): + """check_all_command_guards must also honor cron_mode over gateway classification.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + + from gateway.session_context import set_session_vars, clear_session_vars + tokens = set_session_vars(platform="telegram", chat_id="789") + try: + from unittest.mock import patch as mock_patch + with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"): + result = check_all_command_guards("rm -rf /tmp/stuff", "local") + assert not result["approved"] + assert "BLOCKED" in result["message"] + assert result.get("status") != "approval_required" + finally: + clear_session_vars(tokens) diff --git a/tools/approval.py b/tools/approval.py index 1322098ebc..068748f685 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -100,7 +100,16 @@ def _is_gateway_approval_context() -> bool: Legacy gateway integrations set HERMES_GATEWAY_SESSION in process env. Newer concurrent gateway paths bind HERMES_SESSION_PLATFORM via contextvars so approval mode does not depend on process-global flags. + + Cron jobs are NEVER gateway-approval contexts even when they originate + from a gateway platform (cron binds HERMES_SESSION_PLATFORM via + contextvars for delivery routing). Cron approvals are governed by + ``approvals.cron_mode`` config, not interactive resolve — letting cron + fall through to the gateway branch would submit a pending approval + with no listener and block the job indefinitely. """ + if os.getenv("HERMES_CRON_SESSION"): + return False if os.getenv("HERMES_GATEWAY_SESSION"): return True return bool(_get_session_platform())