From 2c7d3246f2d1811c7cbdebd3cbae06983cdc0187 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Wed, 3 Jun 2026 13:40:50 +0300 Subject: [PATCH] fix(gateway): handle credential broker callbacks --- gateway/platforms/telegram.py | 68 ++++++++++++++++ ...test_telegram_callback_auth_fail_closed.py | 79 ++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index fa55f2db0042..024efc38268e 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -3251,6 +3251,74 @@ async def _handle_callback_query( await self._handle_model_picker_callback(query, data, chat_id) return + # --- Secure credential broker callbacks (cred:a:: | cred:d:) --- + # These buttons are sent by the profile-local secure_credential_broker.py via + # Telegram's Bot API, so the gateway has to handle the callback even though + # it did not create the original message object itself. + if data.startswith("cred:"): + parts = data.split(":") + action = parts[1] if len(parts) > 1 else "" + request_id = parts[2] if len(parts) > 2 else "" + code = parts[3] if len(parts) > 3 else "" + + caller_id = str(getattr(query.from_user, "id", "")) + if not self._is_callback_user_authorized( + caller_id, + chat_id=query_chat_id, + chat_type=str(query_chat_type) if query_chat_type is not None else None, + thread_id=str(query_thread_id) if query_thread_id is not None else None, + user_name=query_user_name, + ): + await query.answer(text="⛔ You are not authorized to approve credentials.") + return + + from hermes_constants import get_hermes_home + + broker = get_hermes_home() / "scripts" / "secure_credential_broker.py" + if action == "a" and request_id and code: + cmd = [sys.executable, str(broker), "approve", request_id, code] + label = "✅ Credential approved" + elif action == "d" and request_id: + cmd = [sys.executable, str(broker), "deny", request_id] + label = "❌ Credential denied" + else: + await query.answer(text="Invalid credential approval data.", show_alert=True) + return + + try: + import subprocess + proc = await asyncio.to_thread( + subprocess.run, + cmd, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + except Exception as exc: + logger.warning("[%s] credential approval callback failed: %s", self.name, exc) + await query.answer(text="Credential approval failed.", show_alert=True) + return + + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "approval failed").strip().splitlines() + msg = detail[-1][:180] if detail else "approval failed" + await query.answer(text=f"Credential approval failed: {msg}", show_alert=True) + return + + user_display = getattr(query.from_user, "first_name", "User") + await query.answer(text=label) + try: + parse_mode = getattr(ParseMode, "MARKDOWN_V2", None) + await query.edit_message_text( + text=self.format_message(f"{label} by {user_display}"), + parse_mode=parse_mode, + reply_markup=None, + ) + except Exception: + pass + return + # --- Gmail-triage callbacks (gt:verb:arg) --- if data.startswith("gt:"): await self._handle_gmail_triage_callback( diff --git a/tests/gateway/test_telegram_callback_auth_fail_closed.py b/tests/gateway/test_telegram_callback_auth_fail_closed.py index 8f6b0fa5afee..d5c4b37ab32b 100644 --- a/tests/gateway/test_telegram_callback_auth_fail_closed.py +++ b/tests/gateway/test_telegram_callback_auth_fail_closed.py @@ -4,12 +4,12 @@ when TELEGRAM_ALLOWED_USERS is empty, instead of allowing everyone. """ +import asyncio import sys import types from types import SimpleNamespace import pytest - from gateway.config import PlatformConfig, Platform @@ -106,3 +106,80 @@ def test_allowlist_wildcard_permits(self, monkeypatch): adapter = _make_adapter() adapter._message_handler = None assert adapter._is_callback_user_authorized("12345") is True + + def test_credential_callback_approves_broker_request(self, monkeypatch, tmp_path): + """cred:a callbacks should invoke the profile-local secure credential broker.""" + adapter = _make_adapter() + adapter._is_callback_user_authorized = lambda *a, **kw: True + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return SimpleNamespace(returncode=0, stdout='{"ok":true}', stderr="") + + monkeypatch.setattr("subprocess.run", fake_run) + + class Query: + data = "cred:a:cr_test123:654321" + from_user = SimpleNamespace(id="12345", first_name="Stavros") + message = SimpleNamespace( + chat_id=42, + chat=SimpleNamespace(type="private"), + message_thread_id=None, + ) + + def __init__(self): + self.answers = [] + self.edits = [] + + async def answer(self, **kwargs): + self.answers.append(kwargs) + + async def edit_message_text(self, **kwargs): + self.edits.append(kwargs) + + query = Query() + update = SimpleNamespace(callback_query=query) + + asyncio.run(adapter._handle_callback_query(update, SimpleNamespace())) + + assert calls + assert calls[0][0][1] == str(tmp_path / "scripts" / "secure_credential_broker.py") + assert calls[0][0][-3:] == ["approve", "cr_test123", "654321"] + assert query.answers == [{"text": "✅ Credential approved"}] + assert query.edits[0]["reply_markup"] is None + + def test_credential_callback_rejects_unauthorized_user(self, monkeypatch): + """Unauthorized cred callbacks must not invoke the broker.""" + adapter = _make_adapter() + adapter._is_callback_user_authorized = lambda *a, **kw: False + + calls = [] + monkeypatch.setattr("subprocess.run", lambda *a, **kw: calls.append((a, kw))) + + class Query: + data = "cred:a:cr_test123:654321" + from_user = SimpleNamespace(id="99999", first_name="Mallory") + message = SimpleNamespace( + chat_id=42, + chat=SimpleNamespace(type="private"), + message_thread_id=None, + ) + + def __init__(self): + self.answers = [] + + async def answer(self, **kwargs): + self.answers.append(kwargs) + + query = Query() + update = SimpleNamespace(callback_query=query) + + asyncio.run(adapter._handle_callback_query(update, SimpleNamespace())) + + assert calls == [] + assert query.answers == [ + {"text": "⛔ You are not authorized to approve credentials."} + ]