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.
This commit is contained in:
teknium1 2026-02-03 10:46:23 -08:00
parent 3e634aa7e4
commit 17a5efb416
9 changed files with 397 additions and 38 deletions

View file

@ -40,6 +40,7 @@ FAL_KEY=
# - modal: Runs in Modal cloud sandboxes (scalable, requires Modal account) # - modal: Runs in Modal cloud sandboxes (scalable, requires Modal account)
TERMINAL_ENV=local TERMINAL_ENV=local
# Container images (for singularity/docker/modal backends) # Container images (for singularity/docker/modal backends)
TERMINAL_DOCKER_IMAGE=python:3.11 TERMINAL_DOCKER_IMAGE=python:3.11
TERMINAL_SINGULARITY_IMAGE=docker://python:3.11 TERMINAL_SINGULARITY_IMAGE=docker://python:3.11

View file

@ -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 System
Configuration files are stored in `~/.hermes/` for easy user access: Configuration files are stored in `~/.hermes/` for easy user access:

View file

@ -187,21 +187,61 @@ hermes config set terminal.backend modal
### 📱 Messaging Gateway ### 📱 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 ```bash
# Configure your bot token # Add to ~/.hermes/.env:
hermes config set TELEGRAM_BOT_TOKEN "your_token" TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
TELEGRAM_ALLOWED_USERS=YOUR_USER_ID # Comma-separated for multiple users
# Start the gateway
hermes gateway
# Or install as a service
hermes gateway install
hermes gateway start
``` ```
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) ### ⏰ Scheduled Tasks (Cron)

View file

@ -23,9 +23,12 @@ model:
# OPTION 1: Local execution (default) # OPTION 1: Local execution (default)
# Commands run directly on your machine in the current directory # 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: terminal:
env_type: "local" env_type: "local"
cwd: "." # Use "." for current directory, or specify absolute path cwd: "." # CLI working directory - "." means current directory
timeout: 180 timeout: 180
lifetime_seconds: 300 lifetime_seconds: 300
# sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext! # sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext!

View file

@ -24,7 +24,7 @@ from typing import Dict, Optional, Any, List
# Add parent directory to path # Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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 from dotenv import load_dotenv
_env_path = Path.home() / '.hermes' / '.env' _env_path = Path.home() / '.hermes' / '.env'
if _env_path.exists(): if _env_path.exists():
@ -32,6 +32,15 @@ if _env_path.exists():
# Also try project .env as fallback # Also try project .env as fallback
load_dotenv() 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 ( from gateway.config import (
Platform, Platform,
GatewayConfig, GatewayConfig,
@ -163,19 +172,63 @@ class GatewayRunner:
return None 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]: async def _handle_message(self, event: MessageEvent) -> Optional[str]:
""" """
Handle an incoming message from any platform. Handle an incoming message from any platform.
This is the core message processing pipeline: This is the core message processing pipeline:
1. Check for commands (/new, /reset, etc.) 1. Check user authorization
2. Get or create session 2. Check for commands (/new, /reset, etc.)
3. Build context for agent 3. Get or create session
4. Run agent conversation 4. Build context for agent
5. Return response 5. Run agent conversation
6. Return response
""" """
source = event.source 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 # Check for reset commands
command = event.get_command() command = event.get_command()
if command in ["new", "reset"]: if command in ["new", "reset"]:

View file

@ -163,6 +163,44 @@ OPTIONAL_ENV_VARS = {
"url": None, "url": None,
"password": True, "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,
},
} }

View file

@ -6,6 +6,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall]
import asyncio import asyncio
import os import os
import signal
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -13,6 +14,70 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve() 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: def is_linux() -> bool:
return sys.platform.startswith('linux') return sys.platform.startswith('linux')
@ -343,29 +408,80 @@ def gateway_command(args):
sys.exit(1) sys.exit(1)
elif subcmd == "stop": elif subcmd == "stop":
if is_linux(): # Try service first, fall back to killing processes directly
systemd_stop() service_available = False
elif is_macos():
launchd_stop() if is_linux() and get_systemd_unit_path().exists():
else: try:
print("Not supported on this platform.") systemd_stop()
sys.exit(1) 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": elif subcmd == "restart":
if is_linux(): # Try service first, fall back to killing and restarting
systemd_restart() service_available = False
elif is_macos():
launchd_restart() if is_linux() and get_systemd_unit_path().exists():
else: try:
print("Not supported on this platform.") systemd_restart()
sys.exit(1) 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": elif subcmd == "status":
deep = getattr(args, 'deep', False) deep = getattr(args, 'deep', False)
if is_linux():
# Check for service first
if is_linux() and get_systemd_unit_path().exists():
systemd_status(deep) systemd_status(deep)
elif is_macos(): elif is_macos() and get_launchd_plist_path().exists():
launchd_status(deep) launchd_status(deep)
else: else:
print("Not supported on this platform.") # Check for manually running processes
sys.exit(1) 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")

View file

@ -591,6 +591,23 @@ def run_setup_wizard(args):
if is_windows: if is_windows:
print_info("Note: On Windows, commands run via cmd.exe or PowerShell") 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): 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") print_warning(" SECURITY WARNING: Sudo password will be stored in plaintext")
sudo_pass = prompt(" Sudo password (leave empty to skip)", password=True) 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) save_env_value("TELEGRAM_BOT_TOKEN", token)
print_success("Telegram token saved") 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)") home_channel = prompt("Home channel ID (optional, for cron delivery)")
if home_channel: if home_channel:
save_env_value("TELEGRAM_HOME_CHANNEL", 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 # Discord
existing_discord = get_env_value('DISCORD_BOT_TOKEN') existing_discord = get_env_value('DISCORD_BOT_TOKEN')
if existing_discord: if existing_discord:
@ -738,10 +781,36 @@ def run_setup_wizard(args):
save_env_value("DISCORD_BOT_TOKEN", token) save_env_value("DISCORD_BOT_TOKEN", token)
print_success("Discord token saved") 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)") home_channel = prompt("Home channel ID (optional, for cron delivery)")
if home_channel: if home_channel:
save_env_value("DISCORD_HOME_CHANNEL", 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) # Step 7: Additional Tools (Optional)
# ========================================================================= # =========================================================================

View file

@ -139,9 +139,11 @@ TOOLSETS = {
# ========================================================================== # ==========================================================================
"hermes-telegram": { "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": [ "tools": [
# Web tools - safe for messaging # Terminal - enabled with dangerous command approval system
"terminal",
# Web tools
"web_search", "web_extract", "web_search", "web_extract",
# Vision - analyze images sent by users # Vision - analyze images sent by users
"vision_analyze", "vision_analyze",