Skip to content

Commit 45cce7e

Browse files
authored
Merge pull request chigwell#233 from omid-io/feat/chat-access-allowlist
feat(security): add per-chat allowlist filter for privacy control (chigwell#212)
2 parents 940c4b5 + f9ad986 commit 45cce7e

7 files changed

Lines changed: 754 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Message sent successfully:
3333
- [Device Identity](#device-identity)
3434
- [Proxy Support](#proxy-support)
3535
- [File Path Security](#file-path-security)
36+
- [Chat Access Privacy (Allowlist)](#chat-access-privacy-allowlist)
3637
- [Docker](#docker)
3738
- [Development](#development)
3839
- [Security Notes](#security-notes)
@@ -597,6 +598,23 @@ From an MCP client configuration, pass the same roots after `main.py`:
597598
}
598599
```
599600

601+
## Chat Access Privacy (Allowlist)
602+
603+
To restrict AI agents or MCP clients to specific chats only (preventing access to all private or corporate conversations), configure `TELEGRAM_ALLOWED_CHAT_IDS`:
604+
605+
```env
606+
# Comma-separated list of allowed chat IDs, supergroup IDs, or usernames
607+
TELEGRAM_ALLOWED_CHAT_IDS=12345678,-100123456789,@allowed_channel
608+
```
609+
610+
When `TELEGRAM_ALLOWED_CHAT_IDS` is set:
611+
- **`list_chats` / `get_chats`:** Only chats matching the allowlist are returned; all other conversations remain completely invisible to the agent.
612+
- **Messaging & Chat Tools (`send_message`, `get_messages`, `get_chat`, `create_poll`, etc.):** Any attempt to interact with a chat outside the allowlist is rejected with a structured error message: `Access to chat '<chat_id>' is restricted by privacy policy (TELEGRAM_ALLOWED_CHAT_IDS)`.
613+
- **Search & Drafts (`search_global`, `get_drafts`):** Global searches and draft listings omit unallowed conversations.
614+
- **Incoming Events:** Notifications and debounce feeds only process events from allowed chats.
615+
616+
If `TELEGRAM_ALLOWED_CHAT_IDS` is unset or empty, the server operates in unrestricted mode (default), preserving 100% backward compatibility.
617+
600618
## Docker
601619

602620
Build the image:

‎main.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,34 @@
1616
from telegram_mcp.runner import _main, main
1717
from telegram_mcp.tools import *
1818

19-
# Backward-compatible alias for callers/tests that monkeypatch main.SERVER_ALLOWED_ROOTS.
19+
# Backward-compatible alias for callers/tests that monkeypatch main.SERVER_ALLOWED_ROOTS / main.ALLOWED_CHAT_IDS.
2020
SERVER_ALLOWED_ROOTS = _runtime.SERVER_ALLOWED_ROOTS
21+
ALLOWED_CHAT_IDS = _runtime.ALLOWED_CHAT_IDS
2122

2223

2324
def _sync_runtime_roots() -> None:
2425
_runtime.SERVER_ALLOWED_ROOTS = SERVER_ALLOWED_ROOTS
2526

2627

28+
def _sync_runtime_chat_allowlist() -> None:
29+
_runtime.ALLOWED_CHAT_IDS = ALLOWED_CHAT_IDS
30+
31+
32+
def is_chat_allowlist_enabled() -> bool:
33+
_sync_runtime_chat_allowlist()
34+
return _runtime.is_chat_allowlist_enabled()
35+
36+
37+
def is_chat_allowed(chat_identifier=None, entity=None) -> bool:
38+
_sync_runtime_chat_allowlist()
39+
return _runtime.is_chat_allowed(chat_identifier, entity)
40+
41+
42+
def check_chat_access(chat_identifier=None, entity=None):
43+
_sync_runtime_chat_allowlist()
44+
return _runtime.check_chat_access(chat_identifier, entity)
45+
46+
2747
async def _get_effective_allowed_roots(ctx):
2848
_sync_runtime_roots()
2949
return await _runtime._get_effective_allowed_roots(ctx)

‎telegram_mcp/runtime.py‎

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from difflib import SequenceMatcher
1414
from datetime import datetime, timedelta, timezone
1515
from enum import Enum
16-
from typing import List, Dict, Optional, Union, Any, get_args
16+
from typing import List, Dict, Optional, Union, Any, Iterable, get_args
1717
from pathlib import Path
1818
from urllib.parse import unquote, urlparse
1919

@@ -879,6 +879,137 @@ async def ensure_connected(cl: TelegramClient = None):
879879
ROOTS_REQUEST_TIMEOUT_DEFAULT = 10.0
880880

881881

882+
# Per-chat access control allowlist configuration (TELEGRAM_ALLOWED_CHAT_IDS)
883+
ALLOWED_CHAT_IDS: Optional[set[Union[int, str]]] = None
884+
CHAT_PARAM_NAMES: frozenset[str] = frozenset({"chat_id", "from_chat_id", "to_chat_id", "channel"})
885+
886+
887+
def _parse_allowed_chat_ids(
888+
raw: Optional[Union[str, Iterable[Union[int, str]]]],
889+
) -> Optional[set[Union[int, str]]]:
890+
"""Parse TELEGRAM_ALLOWED_CHAT_IDS into a set of allowed IDs and usernames.
891+
892+
Supports comma-separated integer IDs (e.g. '12345678,-100123456789') and
893+
usernames/handles (e.g. '@mychat,other_channel').
894+
Also automatically indexes marked variants for bare integers and vice versa
895+
so that both marked IDs (-100...) and bare positive IDs match.
896+
"""
897+
if raw is None:
898+
return None
899+
if isinstance(raw, str):
900+
raw = raw.strip()
901+
if not raw:
902+
return None
903+
tokens = [t.strip() for t in raw.split(",") if t.strip()]
904+
elif isinstance(raw, (list, tuple, set)):
905+
tokens = [str(t).strip() for t in raw if str(t).strip()]
906+
else:
907+
return None
908+
909+
if not tokens:
910+
return None
911+
912+
allowed: set[Union[int, str]] = set()
913+
for token in tokens:
914+
try:
915+
val = int(token)
916+
allowed.add(val)
917+
# If negative supergroup: -100XXXXXXXXXX
918+
if str(val).startswith("-100") and len(str(val)) > 4:
919+
try:
920+
channel_id = int(str(val)[4:])
921+
allowed.add(channel_id)
922+
except ValueError:
923+
pass
924+
# If positive bare ID: add supergroup (-100...) and group (-) variants
925+
elif val > 0:
926+
allowed.add(-1000000000000 - val)
927+
allowed.add(-val)
928+
elif val < 0:
929+
# Basic group negative ID: -XXXXXX
930+
allowed.add(-val)
931+
except ValueError:
932+
clean_handle = token.lstrip("@").strip().lower()
933+
if clean_handle:
934+
allowed.add(clean_handle)
935+
936+
return allowed if allowed else None
937+
938+
939+
def _load_allowed_chat_ids() -> Optional[set[Union[int, str]]]:
940+
"""Load ALLOWED_CHAT_IDS from the TELEGRAM_ALLOWED_CHAT_IDS environment variable."""
941+
return _parse_allowed_chat_ids(os.getenv("TELEGRAM_ALLOWED_CHAT_IDS"))
942+
943+
944+
# Initial load from environment
945+
ALLOWED_CHAT_IDS = _load_allowed_chat_ids()
946+
947+
948+
def get_effective_allowed_chat_ids() -> Optional[set[Union[int, str]]]:
949+
"""Return the currently effective set of allowed chat IDs, or None if allowlist is disabled."""
950+
env_raw = os.getenv("TELEGRAM_ALLOWED_CHAT_IDS")
951+
if env_raw is not None:
952+
return _parse_allowed_chat_ids(env_raw)
953+
return ALLOWED_CHAT_IDS
954+
955+
956+
def is_chat_allowlist_enabled() -> bool:
957+
"""Return True if chat allowlist filtering is active."""
958+
return get_effective_allowed_chat_ids() is not None
959+
960+
961+
def is_chat_allowed(chat_identifier: Any, entity: Any = None) -> bool:
962+
"""Check whether a chat identifier or entity is permitted by the allowlist.
963+
964+
If allowlist is not enabled, always returns True.
965+
"""
966+
allowed = get_effective_allowed_chat_ids()
967+
if allowed is None:
968+
return True
969+
970+
if chat_identifier is not None:
971+
if isinstance(chat_identifier, int):
972+
if chat_identifier in allowed:
973+
return True
974+
elif isinstance(chat_identifier, str):
975+
try:
976+
int_id = int(chat_identifier)
977+
if int_id in allowed:
978+
return True
979+
except ValueError:
980+
clean = chat_identifier.lstrip("@").strip().lower()
981+
if clean and clean in allowed:
982+
return True
983+
984+
if entity is not None:
985+
try:
986+
marked_id = get_marked_id(entity)
987+
if marked_id in allowed:
988+
return True
989+
except Exception:
990+
pass
991+
992+
bare_id = getattr(entity, "id", None)
993+
if isinstance(bare_id, int) and bare_id in allowed:
994+
return True
995+
996+
username = getattr(entity, "username", None)
997+
if username and str(username).lower() in allowed:
998+
return True
999+
1000+
return False
1001+
1002+
1003+
def check_chat_access(chat_identifier: Any, entity: Any = None) -> Optional[str]:
1004+
"""Return an error message if chat access is restricted, or None if allowed."""
1005+
if not is_chat_allowed(chat_identifier, entity):
1006+
return (
1007+
f"Access to chat '{chat_identifier}' is restricted by privacy policy "
1008+
"(TELEGRAM_ALLOWED_CHAT_IDS)."
1009+
)
1010+
return None
1011+
1012+
8821013
# Error code prefix mapping for better error tracing
8831014
class ErrorCategory(str, Enum):
8841015
CHAT = "CHAT"
@@ -890,6 +1021,13 @@ class ErrorCategory(str, Enum):
8901021
AUTH = "AUTH"
8911022
ADMIN = "ADMIN"
8921023
FOLDER = "FOLDER"
1024+
PRIVACY = "PRIVACY"
1025+
1026+
1027+
class ChatAccessDeniedError(Exception):
1028+
"""Exception raised when access to a chat is restricted by privacy policy."""
1029+
1030+
pass
8931031

8941032

8951033
def _is_flood_wait(error: Exception) -> bool:
@@ -1075,6 +1213,34 @@ def validate_single_id(value, p_name):
10751213
)
10761214
kwargs[param_name] = validated_value
10771215

1216+
# Per-chat privacy allowlist enforcement
1217+
if is_chat_allowlist_enabled() and param_name in CHAT_PARAM_NAMES:
1218+
check_target = kwargs[param_name]
1219+
target_items = (
1220+
check_target if isinstance(check_target, list) else [check_target]
1221+
)
1222+
for item in target_items:
1223+
if not is_chat_allowed(item):
1224+
resolved_allowed = False
1225+
if isinstance(item, str):
1226+
try:
1227+
cl = get_client(kwargs.get("account"))
1228+
if cl:
1229+
ent = await resolve_entity(item, cl)
1230+
if is_chat_allowed(item, ent):
1231+
resolved_allowed = True
1232+
except Exception:
1233+
pass
1234+
if not resolved_allowed:
1235+
err = check_chat_access(item)
1236+
return log_and_format_error(
1237+
func.__name__,
1238+
ChatAccessDeniedError(err),
1239+
prefix=ErrorCategory.PRIVACY,
1240+
user_message=err,
1241+
**{param_name: param_value},
1242+
)
1243+
10781244
return await func(*args, **kwargs)
10791245

10801246
return wrapper

‎telegram_mcp/tools/chats.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ async def get_chats(account: str = None, page: int = 1, page_size: int = 20) ->
172172
dialogs = await cl.get_dialogs()
173173
except BotMethodInvalidError:
174174
return "Listing chats/dialogs is not supported for bot accounts (Telegram API restriction: bots cannot fetch dialog lists)."
175+
if is_chat_allowlist_enabled():
176+
dialogs = [d for d in dialogs if is_chat_allowed(get_marked_id(d.entity), d.entity)]
175177
start = (page - 1) * page_size
176178
end = start + page_size
177179
if start >= len(dialogs):
@@ -492,6 +494,10 @@ async def list_chats(
492494
for dialog in dialogs:
493495
entity = dialog.entity
494496

497+
# Enforce privacy allowlist
498+
if is_chat_allowlist_enabled() and not is_chat_allowed(get_marked_id(entity), entity):
499+
continue
500+
495501
# Filter by type if requested
496502
current_type = get_entity_filter_type(entity)
497503

@@ -612,6 +618,16 @@ async def get_chat(chat_id: Union[int, str], account: str = None) -> str:
612618
cl = get_client(account)
613619
entity = await resolve_entity(chat_id, cl)
614620

621+
if is_chat_allowlist_enabled() and not is_chat_allowed(chat_id, entity):
622+
err = check_chat_access(chat_id, entity)
623+
return log_and_format_error(
624+
"get_chat",
625+
ChatAccessDeniedError(err),
626+
prefix=ErrorCategory.PRIVACY,
627+
user_message=err,
628+
chat_id=chat_id,
629+
)
630+
615631
record = {"id": get_marked_id(entity)}
616632

617633
is_user = isinstance(entity, User)
@@ -710,7 +726,10 @@ async def search_public_chats(query: str, limit: int = 20, account: str = None)
710726
cl = get_client(account)
711727
await ensure_connected(cl)
712728
result = await cl(functions.contacts.SearchRequest(q=query, limit=limit))
713-
entities = [format_entity(e) for e in result.chats + result.users]
729+
all_entities = result.chats + result.users
730+
if is_chat_allowlist_enabled():
731+
all_entities = [e for e in all_entities if is_chat_allowed(get_marked_id(e), e)]
732+
entities = [format_entity(e) for e in all_entities]
714733
return json.dumps(entities, indent=2)
715734
except Exception as e:
716735
return log_and_format_error("search_public_chats", e, query=query, limit=limit)
@@ -737,6 +756,7 @@ async def resolve_username(username: str, account: str = None) -> str:
737756
annotations=ToolAnnotations(title="Get Full Chat", openWorldHint=True, readOnlyHint=True)
738757
)
739758
@with_account(readonly=True)
759+
@validate_id("chat_id")
740760
async def get_full_chat(chat_id: Union[int, str], account: str = None) -> str:
741761
"""
742762
Get full info of a channel or group including description/about text.
@@ -752,6 +772,16 @@ async def get_full_chat(chat_id: Union[int, str], account: str = None) -> str:
752772
await ensure_connected(cl)
753773
entity = await resolve_entity(chat_id, cl)
754774

775+
if is_chat_allowlist_enabled() and not is_chat_allowed(chat_id, entity):
776+
err = check_chat_access(chat_id, entity)
777+
return log_and_format_error(
778+
"get_full_chat",
779+
ChatAccessDeniedError(err),
780+
prefix=ErrorCategory.PRIVACY,
781+
user_message=err,
782+
chat_id=chat_id,
783+
)
784+
755785
# Basic ("legacy") groups are not channels: GetFullChannelRequest cannot
756786
# cast an InputPeerChat and raises TypeError. They are served by
757787
# messages.GetFullChatRequest instead.

‎telegram_mcp/tools/events.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def _scan_settled(
5656
"""
5757
soonest_remaining = None
5858
for cid, rec in list(_pending_msgs.items()):
59+
if is_chat_allowlist_enabled() and not is_chat_allowed(cid):
60+
continue
5961
if only is not None and cid != only:
6062
continue
6163
quiet = now - rec["last_ts"]
@@ -207,6 +209,8 @@ async def _on_new_incoming(event) -> None:
207209
if getattr(sender, "bot", False) or getattr(sender, "is_self", False):
208210
return
209211
chat_id = event.chat_id
212+
if is_chat_allowlist_enabled() and not is_chat_allowed(chat_id, sender):
213+
return
210214
now = time.monotonic()
211215
msg_id = event.message.id
212216
rec = _pending_msgs.get(chat_id)
@@ -252,6 +256,7 @@ def register_incoming_handlers() -> None:
252256
title="Wait For New Message", openWorldHint=True, readOnlyHint=True
253257
)
254258
)
259+
@validate_id("chat_id")
255260
async def wait_for_new_message(
256261
timeout: float = 50.0,
257262
chat_id: Optional[Union[int, str]] = None,
@@ -283,7 +288,10 @@ async def wait_for_new_message(
283288
deadline = time.monotonic() + timeout
284289
while True:
285290
pending = {
286-
cid: rec for cid, rec in _pending_msgs.items() if target is None or cid == target
291+
cid: rec
292+
for cid, rec in _pending_msgs.items()
293+
if (target is None or cid == target)
294+
and (not is_chat_allowlist_enabled() or is_chat_allowed(cid))
287295
}
288296
if pending:
289297
chats = [
@@ -323,6 +331,7 @@ async def wait_for_new_message(
323331
title="Wait For Settled Message", openWorldHint=True, readOnlyHint=True
324332
)
325333
)
334+
@validate_id("chat_id")
326335
async def wait_for_settled_message(
327336
settle_ms: int = 6000,
328337
max_wait_ms: int = 50000,

0 commit comments

Comments
 (0)