Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<request_id>:<code> | cred:d:<request_id>) ---
# 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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard-codes a secure_credential_broker.py profile-script contract that current tracked HEAD does not define or produce callbacks for. Please establish a supported extension/interface and an integration test before wiring this into the Telegram adapter.

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(
Expand Down
79 changes: 78 additions & 1 deletion tests/gateway/test_telegram_callback_auth_fail_closed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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."}
]