From 17a5efb416b521b0a89191dd1324e4f161ff9a0b Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 3 Feb 2026 10:46:23 -0800 Subject: [PATCH] Enhance messaging gateway configuration and security features - Added new environment variables for Telegram and Discord bot configurations, including `TELEGRAM_ALLOWED_USERS` and `DISCORD_ALLOWED_USERS`, to restrict bot access to specific users. - Updated documentation in AGENTS.md and README.md to include detailed setup instructions for the messaging gateway, emphasizing the importance of user allowlists for security. - Improved the CLI setup wizard to prompt for allowed user IDs during configuration, enhancing user guidance and security awareness. - Refined the gateway run script to support user authorization checks, ensuring only allowed users can interact with the bot. --- .env.example | 1 + AGENTS.md | 37 ++++++++++ README.md | 62 +++++++++++++--- cli-config.yaml.example | 5 +- gateway/run.py | 65 +++++++++++++++-- hermes_cli/config.py | 38 ++++++++++ hermes_cli/gateway.py | 152 +++++++++++++++++++++++++++++++++++----- hermes_cli/setup.py | 69 ++++++++++++++++++ toolsets.py | 6 +- 9 files changed, 397 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index 9c73f74e..98c5ea19 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ FAL_KEY= # - modal: Runs in Modal cloud sandboxes (scalable, requires Modal account) TERMINAL_ENV=local + # Container images (for singularity/docker/modal backends) TERMINAL_DOCKER_IMAGE=python:3.11 TERMINAL_SINGULARITY_IMAGE=docker://python:3.11 diff --git a/AGENTS.md b/AGENTS.md index c658ae6e..b495704d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,43 @@ The unified `hermes` command provides all functionality: --- +## Messaging Gateway + +The gateway connects Hermes to Telegram, Discord, and WhatsApp. + +### Configuration (in `~/.hermes/.env`): + +```bash +# Telegram +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... # From @BotFather +TELEGRAM_ALLOWED_USERS=123456789,987654 # Comma-separated user IDs (from @userinfobot) + +# Discord +DISCORD_BOT_TOKEN=MTIz... # From Developer Portal +DISCORD_ALLOWED_USERS=123456789012345678 # Comma-separated user IDs +``` + +### Security (User Allowlists): + +**IMPORTANT**: Without an allowlist, anyone who finds your bot can use it! + +The gateway checks `{PLATFORM}_ALLOWED_USERS` environment variables: +- If set: Only listed user IDs can interact with the bot +- If unset: All users are allowed (dangerous with terminal access!) + +Users can find their IDs: +- **Telegram**: Message [@userinfobot](https://t.me/userinfobot) +- **Discord**: Enable Developer Mode, right-click name → Copy ID + +### Platform Toolsets: + +Each platform has a dedicated toolset in `toolsets.py`: +- `hermes-telegram`: Full tools including terminal (with safety checks) +- `hermes-discord`: Full tools including terminal +- `hermes-whatsapp`: Full tools including terminal + +--- + ## Configuration System Configuration files are stored in `~/.hermes/` for easy user access: diff --git a/README.md b/README.md index ab2b3bcf..047bec2e 100644 --- a/README.md +++ b/README.md @@ -187,21 +187,61 @@ hermes config set terminal.backend modal ### 📱 Messaging Gateway -Chat with Hermes from Telegram, Discord, or WhatsApp: +Chat with Hermes from Telegram, Discord, or WhatsApp. + +#### Telegram Setup + +1. **Create a bot:** Message [@BotFather](https://t.me/BotFather) on Telegram, use `/newbot` +2. **Get your user ID:** Message [@userinfobot](https://t.me/userinfobot) - it replies with your numeric ID +3. **Configure:** ```bash -# Configure your bot token -hermes config set TELEGRAM_BOT_TOKEN "your_token" - -# Start the gateway -hermes gateway - -# Or install as a service -hermes gateway install -hermes gateway start +# Add to ~/.hermes/.env: +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... +TELEGRAM_ALLOWED_USERS=YOUR_USER_ID # Comma-separated for multiple users ``` -See [docs/messaging.md](docs/messaging.md) for full setup. +4. **Start the gateway:** + +```bash +hermes gateway # Run in foreground +hermes gateway install # Install as systemd service (Linux) +hermes gateway start # Start the service +``` + +#### Discord Setup + +1. **Create a bot:** Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. **Get your user ID:** Enable Developer Mode in Discord settings, right-click your name → Copy ID +3. **Configure:** + +```bash +# Add to ~/.hermes/.env: +DISCORD_BOT_TOKEN=MTIz... +DISCORD_ALLOWED_USERS=YOUR_USER_ID +``` + +#### Security (Important!) + +**Without an allowlist, anyone who finds your bot can use it!** + +```bash +# Restrict to specific users (recommended): +TELEGRAM_ALLOWED_USERS=123456789,987654321 +DISCORD_ALLOWED_USERS=123456789012345678 + +# Or allow all users in a specific platform: +# (Leave the variable unset - NOT recommended for bots with terminal access) +``` + +#### Gateway Commands + +| Command | Description | +|---------|-------------| +| `/new` or `/reset` | Start fresh conversation | +| `/status` | Show session info | + +See [docs/messaging.md](docs/messaging.md) for WhatsApp and advanced setup. ### ⏰ Scheduled Tasks (Cron) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 81be7a4d..5b4f16ad 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -23,9 +23,12 @@ model: # OPTION 1: Local execution (default) # Commands run directly on your machine in the current directory # ----------------------------------------------------------------------------- +# Working directory behavior: +# - CLI (`hermes` command): Uses "." (current directory where you run hermes) +# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home) terminal: env_type: "local" - cwd: "." # Use "." for current directory, or specify absolute path + cwd: "." # CLI working directory - "." means current directory timeout: 180 lifetime_seconds: 300 # sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext! diff --git a/gateway/run.py b/gateway/run.py index 9c354ebd..de7dd844 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -24,7 +24,7 @@ from typing import Dict, Optional, Any, List # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) -# Load environment variables from ~/.hermes/.env +# Load environment variables from ~/.hermes/.env first from dotenv import load_dotenv _env_path = Path.home() / '.hermes' / '.env' if _env_path.exists(): @@ -32,6 +32,15 @@ if _env_path.exists(): # Also try project .env as fallback load_dotenv() +# Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs) +os.environ["HERMES_QUIET"] = "1" + +# Set terminal working directory for messaging platforms +# Uses MESSAGING_CWD if set, otherwise defaults to home directory +# This is separate from CLI which uses the directory where `hermes` is run +messaging_cwd = os.getenv("MESSAGING_CWD") or str(Path.home()) +os.environ["TERMINAL_CWD"] = messaging_cwd + from gateway.config import ( Platform, GatewayConfig, @@ -163,19 +172,63 @@ class GatewayRunner: return None + def _is_user_authorized(self, source: SessionSource) -> bool: + """ + Check if a user is authorized to use the bot. + + Authorization is checked via environment variables: + - GATEWAY_ALLOWED_USERS: Comma-separated list of user IDs (all platforms) + - TELEGRAM_ALLOWED_USERS: Telegram-specific user IDs + - DISCORD_ALLOWED_USERS: Discord-specific user IDs + + If no allowlist is configured, all users are allowed (open access). + """ + user_id = source.user_id + if not user_id: + return False # Can't verify unknown users + + # Check platform-specific allowlist first + platform_env_map = { + Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", + Platform.DISCORD: "DISCORD_ALLOWED_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", + } + + platform_allowlist = os.getenv(platform_env_map.get(source.platform, "")) + global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "") + + # If no allowlists configured, allow all (backward compatible) + if not platform_allowlist and not global_allowlist: + return True + + # Check if user is in any allowlist + allowed_ids = set() + if platform_allowlist: + allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",")) + if global_allowlist: + allowed_ids.update(uid.strip() for uid in global_allowlist.split(",")) + + return user_id in allowed_ids + async def _handle_message(self, event: MessageEvent) -> Optional[str]: """ Handle an incoming message from any platform. This is the core message processing pipeline: - 1. Check for commands (/new, /reset, etc.) - 2. Get or create session - 3. Build context for agent - 4. Run agent conversation - 5. Return response + 1. Check user authorization + 2. Check for commands (/new, /reset, etc.) + 3. Get or create session + 4. Build context for agent + 5. Run agent conversation + 6. Return response """ source = event.source + # Check if user is authorized + if not self._is_user_authorized(source): + print(f"[gateway] Unauthorized user: {source.user_id} ({source.user_name}) on {source.platform.value}") + return None # Silently ignore unauthorized users + # Check for reset commands command = event.get_command() if command in ["new", "reset"]: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 65443d62..e24e2a3a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -163,6 +163,44 @@ OPTIONAL_ENV_VARS = { "url": None, "password": True, }, + # Messaging platform tokens + "TELEGRAM_BOT_TOKEN": { + "description": "Telegram bot token from @BotFather", + "prompt": "Telegram bot token", + "url": "https://t.me/BotFather", + "password": True, + }, + "TELEGRAM_ALLOWED_USERS": { + "description": "Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)", + "prompt": "Allowed Telegram user IDs (comma-separated)", + "url": "https://t.me/userinfobot", + "password": False, + }, + "DISCORD_BOT_TOKEN": { + "description": "Discord bot token from Developer Portal", + "prompt": "Discord bot token", + "url": "https://discord.com/developers/applications", + "password": True, + }, + "DISCORD_ALLOWED_USERS": { + "description": "Comma-separated Discord user IDs allowed to use the bot", + "prompt": "Allowed Discord user IDs (comma-separated)", + "url": None, + "password": False, + }, + # Terminal configuration + "MESSAGING_CWD": { + "description": "Working directory for terminal commands via messaging (Telegram/Discord/etc). CLI always uses current directory.", + "prompt": "Messaging working directory (default: home)", + "url": None, + "password": False, + }, + "SUDO_PASSWORD": { + "description": "Sudo password for terminal commands requiring root access", + "prompt": "Sudo password", + "url": None, + "password": True, + }, } diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 678a6892..579ea5f1 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -6,6 +6,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall] import asyncio import os +import signal import subprocess import sys from pathlib import Path @@ -13,6 +14,70 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent.resolve() +# ============================================================================= +# Process Management (for manual gateway runs) +# ============================================================================= + +def find_gateway_pids() -> list: + """Find PIDs of running gateway processes.""" + pids = [] + try: + # Look for gateway processes with multiple patterns + patterns = [ + "hermes_cli.main gateway", + "hermes gateway", + "gateway/run.py", + ] + + result = subprocess.run( + ["ps", "aux"], + capture_output=True, + text=True + ) + + for line in result.stdout.split('\n'): + # Skip grep and current process + if 'grep' in line or str(os.getpid()) in line: + continue + + for pattern in patterns: + if pattern in line: + parts = line.split() + if len(parts) > 1: + try: + pid = int(parts[1]) + if pid not in pids: + pids.append(pid) + except ValueError: + continue + break + except Exception: + pass + + return pids + + +def kill_gateway_processes(force: bool = False) -> int: + """Kill any running gateway processes. Returns count killed.""" + pids = find_gateway_pids() + killed = 0 + + for pid in pids: + try: + if force: + os.kill(pid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGTERM) + killed += 1 + except ProcessLookupError: + # Process already gone + pass + except PermissionError: + print(f"⚠ Permission denied to kill PID {pid}") + + return killed + + def is_linux() -> bool: return sys.platform.startswith('linux') @@ -343,29 +408,80 @@ def gateway_command(args): sys.exit(1) elif subcmd == "stop": - if is_linux(): - systemd_stop() - elif is_macos(): - launchd_stop() - else: - print("Not supported on this platform.") - sys.exit(1) + # Try service first, fall back to killing processes directly + service_available = False + + if is_linux() and get_systemd_unit_path().exists(): + try: + systemd_stop() + service_available = True + except subprocess.CalledProcessError: + pass # Fall through to process kill + elif is_macos() and get_launchd_plist_path().exists(): + try: + launchd_stop() + service_available = True + except subprocess.CalledProcessError: + pass + + if not service_available: + # Kill gateway processes directly + killed = kill_gateway_processes() + if killed: + print(f"✓ Stopped {killed} gateway process(es)") + else: + print("✗ No gateway processes found") elif subcmd == "restart": - if is_linux(): - systemd_restart() - elif is_macos(): - launchd_restart() - else: - print("Not supported on this platform.") - sys.exit(1) + # Try service first, fall back to killing and restarting + service_available = False + + if is_linux() and get_systemd_unit_path().exists(): + try: + systemd_restart() + service_available = True + except subprocess.CalledProcessError: + pass + elif is_macos() and get_launchd_plist_path().exists(): + try: + launchd_restart() + service_available = True + except subprocess.CalledProcessError: + pass + + if not service_available: + # Manual restart: kill existing processes + killed = kill_gateway_processes() + if killed: + print(f"✓ Stopped {killed} gateway process(es)") + + import time + time.sleep(2) + + # Start fresh + print("Starting gateway...") + run_gateway(verbose=False) elif subcmd == "status": deep = getattr(args, 'deep', False) - if is_linux(): + + # Check for service first + if is_linux() and get_systemd_unit_path().exists(): systemd_status(deep) - elif is_macos(): + elif is_macos() and get_launchd_plist_path().exists(): launchd_status(deep) else: - print("Not supported on this platform.") - sys.exit(1) + # Check for manually running processes + pids = find_gateway_pids() + if pids: + print(f"✓ Gateway is running (PID: {', '.join(map(str, pids))})") + print(" (Running manually, not as a system service)") + print() + print("To install as a service:") + print(" hermes gateway install") + else: + print("✗ Gateway is not running") + print() + print("To start:") + print(" hermes gateway # Run in foreground") + print(" hermes gateway install # Install as service") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 4b4e5f3b..98420725 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -591,6 +591,23 @@ def run_setup_wizard(args): if is_windows: print_info("Note: On Windows, commands run via cmd.exe or PowerShell") + # Messaging working directory configuration + print_info("") + print_info("Working Directory for Messaging (Telegram/Discord/etc):") + print_info(" The CLI always uses the directory you run 'hermes' from") + print_info(" But messaging bots need a static starting directory") + + current_cwd = get_env_value('MESSAGING_CWD') or str(Path.home()) + print_info(f" Current: {current_cwd}") + + cwd_input = prompt(" Messaging working directory", current_cwd) + # Expand ~ to full path + if cwd_input.startswith('~'): + cwd_expanded = str(Path.home()) + cwd_input[1:] + else: + cwd_expanded = cwd_input + save_env_value("MESSAGING_CWD", cwd_expanded) + if prompt_yes_no(" Enable sudo support? (allows agent to run sudo commands)", False): print_warning(" SECURITY WARNING: Sudo password will be stored in plaintext") sudo_pass = prompt(" Sudo password (leave empty to skip)", password=True) @@ -720,10 +737,36 @@ def run_setup_wizard(args): save_env_value("TELEGRAM_BOT_TOKEN", token) print_success("Telegram token saved") + # Allowed users (security) + print() + print_info("🔒 Security: Restrict who can use your bot") + print_info(" To find your Telegram user ID:") + print_info(" 1. Message @userinfobot on Telegram") + print_info(" 2. It will reply with your numeric ID (e.g., 123456789)") + print() + allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + if allowed_users: + save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("Telegram allowlist configured - only listed users can use the bot") + else: + print_info("⚠️ No allowlist set - anyone who finds your bot can use it!") + home_channel = prompt("Home channel ID (optional, for cron delivery)") if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) + # Check/update existing Telegram allowlist + elif existing_telegram: + existing_allowlist = get_env_value('TELEGRAM_ALLOWED_USERS') + if not existing_allowlist: + print_info("⚠️ Telegram has no user allowlist - anyone can use your bot!") + if prompt_yes_no("Add allowed users now?", True): + print_info(" To find your Telegram user ID: message @userinfobot") + allowed_users = prompt("Allowed user IDs (comma-separated)") + if allowed_users: + save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("Telegram allowlist configured") + # Discord existing_discord = get_env_value('DISCORD_BOT_TOKEN') if existing_discord: @@ -738,10 +781,36 @@ def run_setup_wizard(args): save_env_value("DISCORD_BOT_TOKEN", token) print_success("Discord token saved") + # Allowed users (security) + print() + print_info("🔒 Security: Restrict who can use your bot") + print_info(" To find your Discord user ID:") + print_info(" 1. Enable Developer Mode in Discord settings") + print_info(" 2. Right-click your name → Copy ID") + print() + allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + if allowed_users: + save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("Discord allowlist configured") + else: + print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") + home_channel = prompt("Home channel ID (optional, for cron delivery)") if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) + # Check/update existing Discord allowlist + elif existing_discord: + existing_allowlist = get_env_value('DISCORD_ALLOWED_USERS') + if not existing_allowlist: + print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") + if prompt_yes_no("Add allowed users now?", True): + print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") + allowed_users = prompt("Allowed user IDs (comma-separated)") + if allowed_users: + save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("Discord allowlist configured") + # ========================================================================= # Step 7: Additional Tools (Optional) # ========================================================================= diff --git a/toolsets.py b/toolsets.py index bd6b22dc..5d08731e 100644 --- a/toolsets.py +++ b/toolsets.py @@ -139,9 +139,11 @@ TOOLSETS = { # ========================================================================== "hermes-telegram": { - "description": "Telegram bot toolset - web research, skills, cronjobs (no terminal/browser for security)", + "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)", "tools": [ - # Web tools - safe for messaging + # Terminal - enabled with dangerous command approval system + "terminal", + # Web tools "web_search", "web_extract", # Vision - analyze images sent by users "vision_analyze",