From c7306b6721a74111f3c1ceea4469382d60e6bdf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 06:00:16 +0000 Subject: [PATCH] feat: add Moltbot Secure edition for Railway deployment A lean, secure, self-hosted AI assistant designed for Railway: - Telegram-only channel (allowlist-based access control) - Authenticated webhook receiver for external integrations - Docker sandbox for isolated code execution - Cron scheduler for recurring tasks - Env-only configuration (no config files) - Full audit logging Core files: - secure/config.ts - Environment-only configuration - secure/audit.ts - Audit logging system - secure/agent.ts - AI agent core (Anthropic/OpenAI) - secure/telegram.ts - Telegram bot handler - secure/webhooks.ts - Webhook receiver - secure/sandbox.ts - Docker sandbox execution - secure/scheduler.ts - Cron task scheduler - secure/index.ts - Main entry point - secure/Dockerfile - Minimal container image - secure/railway.json - Railway deployment config https://claude.ai/code/session_015VqJ7gN4vaxtYfYc92UjLs --- SECURE-BOT.md | 225 ++++++++++++++++++++++++++++++ secure/Dockerfile | 51 +++++++ secure/agent.ts | 177 ++++++++++++++++++++++++ secure/audit.ts | 260 +++++++++++++++++++++++++++++++++++ secure/config.ts | 235 +++++++++++++++++++++++++++++++ secure/index.ts | 193 ++++++++++++++++++++++++++ secure/package.json | 26 ++++ secure/railway.json | 13 ++ secure/sandbox.ts | 267 +++++++++++++++++++++++++++++++++++ secure/scheduler.ts | 270 ++++++++++++++++++++++++++++++++++++ secure/telegram.ts | 321 +++++++++++++++++++++++++++++++++++++++++++ secure/tsconfig.json | 20 +++ secure/webhooks.ts | 287 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 2345 insertions(+) create mode 100644 SECURE-BOT.md create mode 100644 secure/Dockerfile create mode 100644 secure/agent.ts create mode 100644 secure/audit.ts create mode 100644 secure/config.ts create mode 100644 secure/index.ts create mode 100644 secure/package.json create mode 100644 secure/railway.json create mode 100644 secure/sandbox.ts create mode 100644 secure/scheduler.ts create mode 100644 secure/telegram.ts create mode 100644 secure/tsconfig.json create mode 100644 secure/webhooks.ts diff --git a/SECURE-BOT.md b/SECURE-BOT.md new file mode 100644 index 000000000..0e270583f --- /dev/null +++ b/SECURE-BOT.md @@ -0,0 +1,225 @@ +# Moltbot Secure Edition + +A lean, secure, self-hosted AI assistant for Railway deployment. + +## Philosophy + +**Your AI agent that runs on your infrastructure, answers only to you, and you can actually audit.** + +- No SaaS middleman +- No data harvesting +- Your keys, your server, your rules + +## Core Principles + +| Principle | Implementation | +|-----------|----------------| +| **Allowlist-only** | Nobody talks to it unless explicitly approved | +| **Env-var config** | No config files to leak, no filesystem secrets | +| **Audit log** | Every interaction logged, inspectable | +| **No phone-home** | Zero telemetry, no central service | +| **Minimal surface** | Small codebase, few deps, easy to read | +| **Your keys** | Direct to Anthropic/OpenAI, no proxy | + +## Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ MOLTBOT SECURE │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Telegram │ │ Webhooks │ │ Scheduler │ │ +│ │ Channel │ │ Receiver │ │ (Cron) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └─────────────────┼─────────────────┘ │ +│ │ │ +│ ┌──────▼───────┐ │ +│ │ Agent │ │ +│ │ Core │ │ +│ └──────┬───────┘ │ +│ │ │ +│ ┌─────────────────┼─────────────────┐ │ +│ │ │ │ │ +│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │ +│ │ AI Model │ │ Sandbox │ │ Audit │ │ +│ │ (Direct) │ │ (Docker) │ │ Logger │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +## Features + +### Telegram (Primary UI) +- Chat with AI (text, voice transcription, images) +- Forward anything for analysis +- Upload docs for Q&A +- `/commands` for quick actions +- **Allowlist-only**: Must be in `ALLOWED_USERS` + +### Webhooks (Inbound) +- Authenticated endpoint at `/hooks/*` +- Receive from GitHub, Stripe, uptime monitors, etc. +- AI summarizes and forwards to Telegram +- Bearer token or `X-Moltbot-Token` header auth + +### Scheduler (Cron) +- Built-in cron expressions +- Morning briefings, monitors, recurring tasks +- `at:` one-shot scheduling +- `every:` interval scheduling + +### Sandbox (Isolated Execution) +- Docker container for code/script execution +- Network isolated by default +- Resource limits (CPU, memory, time) +- Read-only root filesystem +- Ephemeral - destroyed after use + +## Configuration + +All configuration via environment variables. No config files. + +### Required + +```bash +# Bot Identity +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... + +# AI Provider (pick one) +ANTHROPIC_API_KEY=sk-ant-... +# or +OPENAI_API_KEY=sk-... + +# Access Control +ALLOWED_USERS=123456789,987654321 # Telegram user IDs +``` + +### Optional + +```bash +# Webhook Authentication +WEBHOOK_SECRET=your-random-32-char-secret + +# Gateway Auth (for internal API) +MOLTBOT_GATEWAY_TOKEN=another-random-secret + +# Sandbox Settings +SANDBOX_ENABLED=true +SANDBOX_NETWORK=none # none | bridge +SANDBOX_MEMORY=512m +SANDBOX_CPUS=1 + +# Audit Logging +AUDIT_LOG_PATH=/data/audit.jsonl +``` + +## Railway Deployment + +### One-Click Deploy + +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/moltbot-secure) + +### Manual Setup + +1. Create new Railway project +2. Add from GitHub repo +3. Set environment variables: + - `TELEGRAM_BOT_TOKEN` + - `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` + - `ALLOWED_USERS` + - `WEBHOOK_SECRET` (recommended) +4. Add volume at `/data` for persistence +5. Deploy + +### railway.json + +```json +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile.secure" + }, + "deploy": { + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} +``` + +## Security Model + +### What We Block + +- **Unauthorized users**: Only `ALLOWED_USERS` can interact +- **Unauthenticated webhooks**: Require valid token +- **Network in sandbox**: Disabled by default +- **Filesystem access**: Read-only root, tmpfs only +- **Privilege escalation**: All caps dropped +- **Secret leakage**: Automatic redaction in logs + +### What We Log + +Every interaction is logged to `AUDIT_LOG_PATH`: + +```jsonl +{"ts":"2024-01-15T10:30:00Z","type":"message","user":123456789,"text":"...","response":"..."} +{"ts":"2024-01-15T10:30:05Z","type":"webhook","path":"/hooks/github","status":200} +{"ts":"2024-01-15T10:30:10Z","type":"sandbox","command":"python script.py","exit":0} +``` + +### Threat Model + +| Threat | Mitigation | +|--------|------------| +| Unauthorized access | Telegram user ID allowlist | +| Webhook abuse | Bearer token auth, rate limits | +| Code execution escape | Docker isolation, no network, caps dropped | +| Secret exposure | Env-only config, log redaction | +| Model prompt injection | Sandboxed tool execution | + +## What's NOT Included + +Intentionally removed for security/simplicity: + +- Web UI / Setup wizard +- WebSocket device pairing +- Plugin/extension system +- WhatsApp/Signal/iMessage/Discord +- Multi-account support +- Browser automation sandbox +- File-based configuration + +## Development + +```bash +# Install dependencies +pnpm install + +# Run in dev mode +TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 pnpm dev:secure + +# Build +pnpm build:secure + +# Test +pnpm test:secure +``` + +## Directory Structure (Secure Edition) + +``` +secure/ +├── index.ts # Entry point +├── config.ts # Env-only config loader +├── telegram.ts # Telegram bot (grammy) +├── webhooks.ts # Webhook receiver +├── scheduler.ts # Cron service +├── sandbox.ts # Docker sandbox +├── audit.ts # Audit logger +├── agent.ts # AI agent core +└── Dockerfile # Minimal container +``` diff --git a/secure/Dockerfile b/secure/Dockerfile new file mode 100644 index 000000000..29d9097e0 --- /dev/null +++ b/secure/Dockerfile @@ -0,0 +1,51 @@ +# Moltbot Secure - Minimal Docker Image +# Lean, secure, self-hosted AI assistant for Railway + +FROM node:22-slim AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy package files +COPY package.json pnpm-lock.yaml ./ +COPY secure/package.json ./secure/ + +# Install dependencies +RUN pnpm install --frozen-lockfile --prod=false + +# Copy source +COPY secure/ ./secure/ +COPY tsconfig.json ./ + +# Build TypeScript +RUN pnpm exec tsc --project secure/tsconfig.json + +# Production image +FROM node:22-slim AS runner + +# Security: Run as non-root user +RUN useradd -m -u 1000 moltbot +USER moltbot + +WORKDIR /app + +# Copy built files and production deps +COPY --from=builder --chown=moltbot:moltbot /app/node_modules ./node_modules +COPY --from=builder --chown=moltbot:moltbot /app/secure/dist ./dist +COPY --from=builder --chown=moltbot:moltbot /app/package.json ./ + +# Create data directory for audit logs +RUN mkdir -p /app/data + +ENV NODE_ENV=production +ENV PORT=8080 + +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1))" || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/secure/agent.ts b/secure/agent.ts new file mode 100644 index 000000000..8a98c029a --- /dev/null +++ b/secure/agent.ts @@ -0,0 +1,177 @@ +/** + * Moltbot Secure - Agent Core + * + * Minimal AI agent that handles conversations. + * Direct API calls to Anthropic or OpenAI - no intermediaries. + */ + +import Anthropic from "@anthropic-ai/sdk"; +import OpenAI from "openai"; +import type { SecureConfig } from "./config.js"; +import type { AuditLogger } from "./audit.js"; + +export type Message = { + role: "user" | "assistant"; + content: string; +}; + +export type AgentResponse = { + text: string; + usage?: { + inputTokens: number; + outputTokens: number; + }; +}; + +export type AgentCore = { + chat: (messages: Message[], systemPrompt?: string) => Promise; + provider: "anthropic" | "openai"; +}; + +const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"; +const DEFAULT_OPENAI_MODEL = "gpt-4o"; + +const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant running as a secure, self-hosted bot. + +You are direct, concise, and helpful. You can: +- Answer questions and have conversations +- Analyze images and documents shared with you +- Help with coding and technical tasks +- Summarize content and extract information + +When you receive webhook notifications, summarize them helpfully for the user. + +Be security-conscious: +- Never reveal API keys, tokens, or secrets +- Don't execute commands that could harm the system +- Warn users about potentially dangerous operations`; + +function createAnthropicAgent(config: SecureConfig, audit: AuditLogger): AgentCore { + const client = new Anthropic({ + apiKey: config.ai.apiKey, + }); + + const model = config.ai.model || DEFAULT_ANTHROPIC_MODEL; + + return { + provider: "anthropic", + async chat(messages: Message[], systemPrompt?: string): Promise { + try { + const response = await client.messages.create({ + model, + max_tokens: 4096, + system: systemPrompt || DEFAULT_SYSTEM_PROMPT, + messages: messages.map((m) => ({ + role: m.role, + content: m.content, + })), + }); + + const text = response.content + .filter((block): block is Anthropic.TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); + + return { + text, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } catch (err) { + audit.error({ + error: `Anthropic API error: ${err instanceof Error ? err.message : String(err)}`, + }); + throw err; + } + }, + }; +} + +function createOpenAIAgent(config: SecureConfig, audit: AuditLogger): AgentCore { + const client = new OpenAI({ + apiKey: config.ai.apiKey, + }); + + const model = config.ai.model || DEFAULT_OPENAI_MODEL; + + return { + provider: "openai", + async chat(messages: Message[], systemPrompt?: string): Promise { + try { + const response = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT }, + ...messages.map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content, + })), + ], + }); + + const text = response.choices[0]?.message?.content || ""; + + return { + text, + usage: response.usage + ? { + inputTokens: response.usage.prompt_tokens, + outputTokens: response.usage.completion_tokens, + } + : undefined, + }; + } catch (err) { + audit.error({ + error: `OpenAI API error: ${err instanceof Error ? err.message : String(err)}`, + }); + throw err; + } + }, + }; +} + +export function createAgent(config: SecureConfig, audit: AuditLogger): AgentCore { + if (config.ai.provider === "anthropic") { + return createAnthropicAgent(config, audit); + } + return createOpenAIAgent(config, audit); +} + +/** + * Simple in-memory conversation store + * For Railway, consider using Redis or persistent storage + */ +export type ConversationStore = { + get: (userId: number) => Message[]; + add: (userId: number, message: Message) => void; + clear: (userId: number) => void; +}; + +const MAX_HISTORY = 20; + +export function createConversationStore(): ConversationStore { + const conversations = new Map(); + + return { + get(userId: number): Message[] { + return conversations.get(userId) || []; + }, + + add(userId: number, message: Message): void { + const history = conversations.get(userId) || []; + history.push(message); + // Keep only last N messages + if (history.length > MAX_HISTORY) { + history.splice(0, history.length - MAX_HISTORY); + } + conversations.set(userId, history); + }, + + clear(userId: number): void { + conversations.delete(userId); + }, + }; +} diff --git a/secure/audit.ts b/secure/audit.ts new file mode 100644 index 000000000..6351f1673 --- /dev/null +++ b/secure/audit.ts @@ -0,0 +1,260 @@ +/** + * Moltbot Secure - Audit Logger + * + * Every interaction is logged for transparency and debugging. + * Logs are append-only JSONL format. + */ + +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export type AuditEventType = + | "startup" + | "shutdown" + | "message" + | "message_blocked" + | "webhook" + | "webhook_blocked" + | "sandbox" + | "cron" + | "error"; + +export type AuditEvent = { + ts: string; + type: AuditEventType; + userId?: number; + username?: string; + text?: string; + response?: string; + path?: string; + status?: number; + command?: string; + exitCode?: number; + jobId?: string; + jobName?: string; + error?: string; + durationMs?: number; + metadata?: Record; +}; + +export type AuditLogger = { + log: (event: Omit) => void; + startup: () => void; + shutdown: () => void; + message: (params: { + userId: number; + username?: string; + text: string; + response?: string; + durationMs?: number; + }) => void; + messageBlocked: (params: { + userId: number; + username?: string; + reason: string; + }) => void; + webhook: (params: { + path: string; + status: number; + durationMs?: number; + }) => void; + webhookBlocked: (params: { + path: string; + reason: string; + }) => void; + sandbox: (params: { + command: string; + exitCode: number; + durationMs?: number; + }) => void; + cron: (params: { + jobId: string; + jobName: string; + status: "ok" | "error" | "skipped"; + error?: string; + durationMs?: number; + }) => void; + error: (params: { + error: string; + metadata?: Record; + }) => void; +}; + +/** + * Redact sensitive patterns from text + */ +function redact(text: string): string { + // Redact common secret patterns + return text + // API keys + .replace(/sk-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]") + .replace(/sk-ant-[a-zA-Z0-9-]{20,}/g, "[REDACTED_ANTHROPIC_KEY]") + // Tokens + .replace(/\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b/g, "[REDACTED_TG_TOKEN]") + // Bearer tokens + .replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, "Bearer [REDACTED]") + // Passwords in URLs + .replace(/:\/\/[^:]+:[^@]+@/g, "://[REDACTED]@") + // Generic secrets + .replace(/(['"]?(?:password|secret|token|key|apikey|api_key)['"]?\s*[=:]\s*)['"][^'"]+['"]/gi, "$1[REDACTED]"); +} + +export function createAuditLogger(opts: { + enabled: boolean; + logPath: string; +}): AuditLogger { + const { enabled, logPath } = opts; + + // Ensure log directory exists + if (enabled) { + try { + mkdirSync(dirname(logPath), { recursive: true }); + } catch { + // Directory may already exist + } + } + + function write(event: AuditEvent): void { + if (!enabled) return; + + // Redact sensitive data + const redacted: AuditEvent = { + ...event, + text: event.text ? redact(event.text) : undefined, + response: event.response ? redact(event.response) : undefined, + command: event.command ? redact(event.command) : undefined, + error: event.error ? redact(event.error) : undefined, + }; + + try { + const line = JSON.stringify(redacted) + "\n"; + appendFileSync(logPath, line, { encoding: "utf-8" }); + } catch (err) { + // Log to stderr as fallback + console.error("[audit] Failed to write audit log:", err); + console.error("[audit]", JSON.stringify(redacted)); + } + } + + const logger: AuditLogger = { + log: (event) => { + write({ ...event, ts: new Date().toISOString() }); + }, + + startup: () => { + write({ + ts: new Date().toISOString(), + type: "startup", + metadata: { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }, + }); + }, + + shutdown: () => { + write({ + ts: new Date().toISOString(), + type: "shutdown", + }); + }, + + message: (params) => { + write({ + ts: new Date().toISOString(), + type: "message", + userId: params.userId, + username: params.username, + text: params.text, + response: params.response, + durationMs: params.durationMs, + }); + }, + + messageBlocked: (params) => { + write({ + ts: new Date().toISOString(), + type: "message_blocked", + userId: params.userId, + username: params.username, + error: params.reason, + }); + }, + + webhook: (params) => { + write({ + ts: new Date().toISOString(), + type: "webhook", + path: params.path, + status: params.status, + durationMs: params.durationMs, + }); + }, + + webhookBlocked: (params) => { + write({ + ts: new Date().toISOString(), + type: "webhook_blocked", + path: params.path, + error: params.reason, + }); + }, + + sandbox: (params) => { + write({ + ts: new Date().toISOString(), + type: "sandbox", + command: params.command, + exitCode: params.exitCode, + durationMs: params.durationMs, + }); + }, + + cron: (params) => { + write({ + ts: new Date().toISOString(), + type: "cron", + jobId: params.jobId, + jobName: params.jobName, + status: params.status === "ok" ? 200 : params.status === "skipped" ? 204 : 500, + error: params.error, + durationMs: params.durationMs, + }); + }, + + error: (params) => { + write({ + ts: new Date().toISOString(), + type: "error", + error: params.error, + metadata: params.metadata, + }); + }, + }; + + return logger; +} + +/** + * Console logger for development/debugging + */ +export function createConsoleAuditLogger(): AuditLogger { + const log = (event: Omit) => { + const ts = new Date().toISOString(); + console.log(`[audit] ${ts} ${event.type}`, JSON.stringify(event, null, 2)); + }; + + return { + log, + startup: () => log({ type: "startup" }), + shutdown: () => log({ type: "shutdown" }), + message: (p) => log({ type: "message", ...p }), + messageBlocked: (p) => log({ type: "message_blocked", userId: p.userId, username: p.username, error: p.reason }), + webhook: (p) => log({ type: "webhook", ...p }), + webhookBlocked: (p) => log({ type: "webhook_blocked", path: p.path, error: p.reason }), + sandbox: (p) => log({ type: "sandbox", ...p }), + cron: (p) => log({ type: "cron", jobId: p.jobId, jobName: p.jobName, status: p.status === "ok" ? 200 : 500, error: p.error, durationMs: p.durationMs }), + error: (p) => log({ type: "error", ...p }), + }; +} diff --git a/secure/config.ts b/secure/config.ts new file mode 100644 index 000000000..9411cabdd --- /dev/null +++ b/secure/config.ts @@ -0,0 +1,235 @@ +/** + * Moltbot Secure - Environment-only Configuration + * + * All configuration via environment variables. + * No config files, no filesystem secrets. + */ + +export type SecureConfig = { + // Telegram + telegram: { + botToken: string; + allowedUsers: number[]; + }; + + // AI Provider + ai: { + provider: "anthropic" | "openai"; + apiKey: string; + model?: string; + }; + + // Webhooks + webhooks: { + enabled: boolean; + secret: string; + basePath: string; + }; + + // Sandbox + sandbox: { + enabled: boolean; + image: string; + network: "none" | "bridge"; + memory: string; + cpus: string; + timeoutMs: number; + }; + + // Scheduler + scheduler: { + enabled: boolean; + }; + + // Audit + audit: { + enabled: boolean; + logPath: string; + }; + + // Server + server: { + port: number; + host: string; + gatewayToken: string; + }; +}; + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function optional(name: string, defaultValue: string): string { + return process.env[name] || defaultValue; +} + +function optionalBool(name: string, defaultValue: boolean): boolean { + const value = process.env[name]; + if (!value) return defaultValue; + return value.toLowerCase() === "true" || value === "1"; +} + +function optionalInt(name: string, defaultValue: number): number { + const value = process.env[name]; + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +function parseAllowedUsers(value: string): number[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0); +} + +function detectAiProvider(): { provider: "anthropic" | "openai"; apiKey: string } { + const anthropicKey = process.env.ANTHROPIC_API_KEY; + const openaiKey = process.env.OPENAI_API_KEY; + + if (anthropicKey) { + return { provider: "anthropic", apiKey: anthropicKey }; + } + if (openaiKey) { + return { provider: "openai", apiKey: openaiKey }; + } + + throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY"); +} + +function generateSecureToken(): string { + // Generate a secure random token if not provided + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const randomValues = new Uint8Array(32); + crypto.getRandomValues(randomValues); + for (const byte of randomValues) { + result += chars[byte % chars.length]; + } + return result; +} + +export function loadSecureConfig(): SecureConfig { + // Required: Telegram + const botToken = required("TELEGRAM_BOT_TOKEN"); + const allowedUsersRaw = required("ALLOWED_USERS"); + const allowedUsers = parseAllowedUsers(allowedUsersRaw); + + if (allowedUsers.length === 0) { + throw new Error("ALLOWED_USERS must contain at least one valid Telegram user ID"); + } + + // Required: AI Provider + const { provider, apiKey } = detectAiProvider(); + + // Optional: Webhooks + const webhooksEnabled = optionalBool("WEBHOOKS_ENABLED", true); + const webhookSecret = optional("WEBHOOK_SECRET", generateSecureToken()); + + // Optional: Sandbox + const sandboxEnabled = optionalBool("SANDBOX_ENABLED", true); + + // Optional: Scheduler + const schedulerEnabled = optionalBool("SCHEDULER_ENABLED", true); + + // Optional: Audit + const auditEnabled = optionalBool("AUDIT_ENABLED", true); + + // Optional: Server + const port = optionalInt("PORT", 8080); + + return { + telegram: { + botToken, + allowedUsers, + }, + ai: { + provider, + apiKey, + model: process.env.AI_MODEL, + }, + webhooks: { + enabled: webhooksEnabled, + secret: webhookSecret, + basePath: optional("WEBHOOK_BASE_PATH", "/hooks"), + }, + sandbox: { + enabled: sandboxEnabled, + image: optional("SANDBOX_IMAGE", "moltbot/sandbox:latest"), + network: (optional("SANDBOX_NETWORK", "none") as "none" | "bridge"), + memory: optional("SANDBOX_MEMORY", "512m"), + cpus: optional("SANDBOX_CPUS", "1"), + timeoutMs: optionalInt("SANDBOX_TIMEOUT_MS", 60000), + }, + scheduler: { + enabled: schedulerEnabled, + }, + audit: { + enabled: auditEnabled, + logPath: optional("AUDIT_LOG_PATH", "/data/audit.jsonl"), + }, + server: { + port, + host: optional("HOST", "0.0.0.0"), + gatewayToken: optional("MOLTBOT_GATEWAY_TOKEN", generateSecureToken()), + }, + }; +} + +/** + * Validate config at startup and log warnings + */ +export function validateConfig(config: SecureConfig): string[] { + const warnings: string[] = []; + + // Check for weak security settings + if (config.sandbox.enabled && config.sandbox.network === "bridge") { + warnings.push("SECURITY: Sandbox network is 'bridge' - containers can access network"); + } + + if (config.telegram.allowedUsers.length > 10) { + warnings.push(`Large allowlist (${config.telegram.allowedUsers.length} users) - review if intentional`); + } + + if (!config.audit.enabled) { + warnings.push("SECURITY: Audit logging is disabled - no interaction records will be kept"); + } + + return warnings; +} + +/** + * Redact sensitive values for logging + */ +export function redactConfig(config: SecureConfig): Record { + return { + telegram: { + botToken: config.telegram.botToken.slice(0, 8) + "...", + allowedUsers: config.telegram.allowedUsers, + }, + ai: { + provider: config.ai.provider, + apiKey: config.ai.apiKey.slice(0, 8) + "...", + model: config.ai.model, + }, + webhooks: { + enabled: config.webhooks.enabled, + secret: "[REDACTED]", + basePath: config.webhooks.basePath, + }, + sandbox: config.sandbox, + scheduler: config.scheduler, + audit: config.audit, + server: { + port: config.server.port, + host: config.server.host, + gatewayToken: "[REDACTED]", + }, + }; +} diff --git a/secure/index.ts b/secure/index.ts new file mode 100644 index 000000000..f0f9104c3 --- /dev/null +++ b/secure/index.ts @@ -0,0 +1,193 @@ +/** + * Moltbot Secure - Entry Point + * + * Lean, secure, self-hosted AI assistant for Railway. + * + * Usage: + * TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 npx ts-node secure/index.ts + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { loadSecureConfig, validateConfig, redactConfig } from "./config.js"; +import { createAuditLogger } from "./audit.js"; +import { createAgent, createConversationStore } from "./agent.js"; +import { createTelegramBot } from "./telegram.js"; +import { createWebhookHandler } from "./webhooks.js"; +import { createSandboxRunner } from "./sandbox.js"; +import { createScheduler } from "./scheduler.js"; + +async function main() { + console.log("=".repeat(50)); + console.log(" MOLTBOT SECURE"); + console.log(" Lean, secure, self-hosted AI assistant"); + console.log("=".repeat(50)); + console.log(); + + // Load configuration + console.log("[init] Loading configuration..."); + const config = loadSecureConfig(); + + // Validate and warn + const warnings = validateConfig(config); + if (warnings.length > 0) { + console.log("[init] Configuration warnings:"); + for (const w of warnings) { + console.log(` - ${w}`); + } + } + + // Log redacted config + console.log("[init] Configuration loaded:"); + console.log(JSON.stringify(redactConfig(config), null, 2)); + console.log(); + + // Create audit logger + console.log("[init] Creating audit logger..."); + const audit = createAuditLogger({ + enabled: config.audit.enabled, + logPath: config.audit.logPath, + }); + audit.startup(); + + // Create AI agent + console.log(`[init] Creating AI agent (${config.ai.provider})...`); + const agent = createAgent(config, audit); + + // Create conversation store + const conversations = createConversationStore(); + + // Create Telegram bot + console.log("[init] Creating Telegram bot..."); + const telegram = createTelegramBot({ + config, + audit, + agent, + conversations, + }); + + // Create webhook handler + console.log("[init] Creating webhook handler..."); + const webhooks = createWebhookHandler({ + config, + audit, + agent, + telegramBot: telegram.bot, + }); + + // Create sandbox runner + console.log("[init] Creating sandbox runner..."); + const sandbox = createSandboxRunner(config, audit); + const sandboxAvailable = await sandbox.isAvailable(); + console.log(`[init] Sandbox available: ${sandboxAvailable}`); + + // Create scheduler + console.log("[init] Creating scheduler..."); + const scheduler = createScheduler({ + config, + audit, + agent, + telegramBot: telegram.bot, + }); + + // Create HTTP server + console.log("[init] Creating HTTP server..."); + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + + // Health check + if (url.pathname === "/health" || url.pathname === "/healthz") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + telegram: "connected", + sandbox: sandboxAvailable ? "available" : "unavailable", + })); + return; + } + + // Readiness check + if (url.pathname === "/ready") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain"); + res.end("ready"); + return; + } + + // Webhook handler + if (await webhooks.handleRequest(req, res)) { + return; + } + + // 404 for everything else + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("Not Found"); + }); + + // Graceful shutdown + let isShuttingDown = false; + + async function shutdown(signal: string) { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log(`\n[shutdown] Received ${signal}, shutting down...`); + + audit.shutdown(); + + try { + scheduler.stop(); + await telegram.stop(); + + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + + console.log("[shutdown] Shutdown complete"); + process.exit(0); + } catch (err) { + console.error("[shutdown] Error during shutdown:", err); + process.exit(1); + } + } + + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + + // Start everything + console.log("[start] Starting services..."); + + // Start HTTP server + server.listen(config.server.port, config.server.host, () => { + console.log(`[start] HTTP server listening on ${config.server.host}:${config.server.port}`); + }); + + // Start scheduler + scheduler.start(); + + // Start Telegram bot (polling mode for simplicity) + await telegram.start(); + + console.log(); + console.log("=".repeat(50)); + console.log(" MOLTBOT SECURE IS RUNNING"); + console.log(); + console.log(` Telegram: Polling mode`); + console.log(` Webhooks: http://localhost:${config.server.port}${config.webhooks.basePath}/*`); + console.log(` Health: http://localhost:${config.server.port}/health`); + console.log(` Allowed: ${config.telegram.allowedUsers.length} users`); + console.log(); + console.log(" Press Ctrl+C to stop"); + console.log("=".repeat(50)); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/secure/package.json b/secure/package.json new file mode 100644 index 000000000..90ccb130b --- /dev/null +++ b/secure/package.json @@ -0,0 +1,26 @@ +{ + "name": "moltbot-secure", + "version": "1.0.0", + "description": "Lean, secure, self-hosted AI assistant for Railway", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx index.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "cron": "^3.1.7", + "grammy": "^1.21.1", + "openai": "^4.77.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/secure/railway.json b/secure/railway.json new file mode 100644 index 000000000..39c1c8402 --- /dev/null +++ b/secure/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "secure/Dockerfile" + }, + "deploy": { + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5 + } +} diff --git a/secure/sandbox.ts b/secure/sandbox.ts new file mode 100644 index 000000000..f7c087dd8 --- /dev/null +++ b/secure/sandbox.ts @@ -0,0 +1,267 @@ +/** + * Moltbot Secure - Sandbox Execution + * + * Isolated Docker container for code/script execution. + * Security-first: no network, read-only root, resource limits. + */ + +import { spawn } from "node:child_process"; +import type { SecureConfig } from "./config.js"; +import type { AuditLogger } from "./audit.js"; + +export type SandboxResult = { + exitCode: number; + stdout: string; + stderr: string; + timedOut: boolean; + durationMs: number; +}; + +export type SandboxRunner = { + run: (command: string, stdin?: string) => Promise; + isAvailable: () => Promise; +}; + +/** + * Check if Docker is available + */ +async function checkDocker(): Promise { + return new Promise((resolve) => { + const proc = spawn("docker", ["version"], { + stdio: ["ignore", "ignore", "ignore"], + }); + proc.on("error", () => resolve(false)); + proc.on("close", (code) => resolve(code === 0)); + }); +} + +/** + * Build Docker run arguments for secure execution + */ +function buildDockerArgs(config: SecureConfig["sandbox"], command: string): string[] { + const args: string[] = [ + "run", + "--rm", // Remove container after exit + "-i", // Interactive (for stdin) + + // Security: No network by default + `--network=${config.network}`, + + // Security: Read-only root filesystem + "--read-only", + + // Security: tmpfs for writable areas + "--tmpfs=/tmp:rw,noexec,nosuid,size=64m", + "--tmpfs=/var/tmp:rw,noexec,nosuid,size=64m", + + // Security: Drop all capabilities + "--cap-drop=ALL", + + // Security: No new privileges + "--security-opt=no-new-privileges", + + // Resource limits + `--memory=${config.memory}`, + `--cpus=${config.cpus}`, + "--pids-limit=100", + + // Timeout handled externally, but set a ulimit too + "--ulimit=cpu=60:60", + + // Working directory + "--workdir=/workspace", + + // Image + config.image, + + // Command (via shell for flexibility) + "sh", + "-c", + command, + ]; + + return args; +} + +export function createSandboxRunner(config: SecureConfig, audit: AuditLogger): SandboxRunner { + const sandboxConfig = config.sandbox; + + return { + async isAvailable(): Promise { + if (!sandboxConfig.enabled) return false; + return checkDocker(); + }, + + async run(command: string, stdin?: string): Promise { + const startTime = Date.now(); + + if (!sandboxConfig.enabled) { + return { + exitCode: 1, + stdout: "", + stderr: "Sandbox is disabled", + timedOut: false, + durationMs: 0, + }; + } + + return new Promise((resolve) => { + const args = buildDockerArgs(sandboxConfig, command); + + const proc = spawn("docker", args, { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + let resolved = false; + + const finish = (exitCode: number) => { + if (resolved) return; + resolved = true; + + const durationMs = Date.now() - startTime; + + audit.sandbox({ + command, + exitCode, + durationMs, + }); + + resolve({ + exitCode, + stdout: stdout.slice(0, 10000), // Limit output size + stderr: stderr.slice(0, 10000), + timedOut, + durationMs, + }); + }; + + // Timeout + const timeout = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, sandboxConfig.timeoutMs); + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + // Prevent memory exhaustion + if (stdout.length > 100000) { + proc.kill("SIGKILL"); + } + }); + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + if (stderr.length > 100000) { + proc.kill("SIGKILL"); + } + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + stderr += `\nProcess error: ${err.message}`; + finish(1); + }); + + proc.on("close", (code) => { + clearTimeout(timeout); + finish(code ?? 1); + }); + + // Write stdin if provided + if (stdin && proc.stdin) { + proc.stdin.write(stdin); + proc.stdin.end(); + } else { + proc.stdin?.end(); + } + }); + }, + }; +} + +/** + * Parse sandbox command from user message + * Returns null if message doesn't request code execution + */ +export function parseSandboxRequest(text: string): { + language: string; + code: string; +} | null { + // Match code blocks with language + const codeBlockMatch = text.match(/```(\w+)?\n([\s\S]*?)```/); + if (codeBlockMatch) { + const language = codeBlockMatch[1] || "sh"; + const code = codeBlockMatch[2].trim(); + return { language, code }; + } + + // Match /run command + const runMatch = text.match(/^\/run\s+(.+)$/s); + if (runMatch) { + return { language: "sh", code: runMatch[1].trim() }; + } + + // Match /python command + const pythonMatch = text.match(/^\/python\s+(.+)$/s); + if (pythonMatch) { + return { language: "python", code: pythonMatch[1].trim() }; + } + + return null; +} + +/** + * Build execution command for language + */ +export function buildCommand(language: string, code: string): string { + switch (language.toLowerCase()) { + case "python": + case "py": + // Write code to temp file and execute + return `python3 -c ${JSON.stringify(code)}`; + + case "javascript": + case "js": + case "node": + return `node -e ${JSON.stringify(code)}`; + + case "bash": + case "sh": + case "shell": + return code; + + default: + // Default to shell + return code; + } +} + +/** + * Format sandbox result for display + */ +export function formatSandboxResult(result: SandboxResult): string { + let output = ""; + + if (result.timedOut) { + output += "**Timed out**\n\n"; + } + + if (result.stdout) { + output += "**Output:**\n```\n" + result.stdout.trim() + "\n```\n"; + } + + if (result.stderr) { + output += "**Errors:**\n```\n" + result.stderr.trim() + "\n```\n"; + } + + if (!result.stdout && !result.stderr) { + output += result.exitCode === 0 ? "Command completed (no output)" : "Command failed (no output)"; + } + + output += `\n_Exit code: ${result.exitCode}, Duration: ${result.durationMs}ms_`; + + return output; +} diff --git a/secure/scheduler.ts b/secure/scheduler.ts new file mode 100644 index 000000000..976a107ee --- /dev/null +++ b/secure/scheduler.ts @@ -0,0 +1,270 @@ +/** + * Moltbot Secure - Task Scheduler + * + * Simple cron-like scheduler for recurring tasks. + * Stores jobs in memory or optionally persists to file. + */ + +import { CronJob } from "cron"; +import type { SecureConfig } from "./config.js"; +import type { AuditLogger } from "./audit.js"; +import type { AgentCore } from "./agent.js"; +import type { Bot } from "grammy"; +import { sendToUser } from "./telegram.js"; + +export type ScheduledTask = { + id: string; + name: string; + schedule: string; // Cron expression + prompt: string; // What to ask the AI + enabled: boolean; + lastRun?: Date; + lastStatus?: "ok" | "error"; + lastError?: string; +}; + +export type Scheduler = { + addTask: (task: Omit) => string; + removeTask: (id: string) => boolean; + enableTask: (id: string, enabled: boolean) => boolean; + listTasks: () => ScheduledTask[]; + runTask: (id: string) => Promise; + start: () => void; + stop: () => void; +}; + +export type SchedulerDeps = { + config: SecureConfig; + audit: AuditLogger; + agent: AgentCore; + telegramBot: Bot; +}; + +function generateId(): string { + return Math.random().toString(36).substring(2, 10); +} + +export function createScheduler(deps: SchedulerDeps): Scheduler { + const { config, audit, agent, telegramBot } = deps; + const tasks = new Map(); + const cronJobs = new Map(); + + async function executeTask(task: ScheduledTask): Promise { + const startTime = Date.now(); + + try { + // Run the AI with the task prompt + const response = await agent.chat([ + { role: "user", content: task.prompt }, + ]); + + // Notify users + const message = `**Scheduled Task: ${task.name}**\n\n${response.text}`; + for (const userId of config.telegram.allowedUsers) { + await sendToUser(telegramBot, userId, message); + } + + task.lastRun = new Date(); + task.lastStatus = "ok"; + task.lastError = undefined; + + audit.cron({ + jobId: task.id, + jobName: task.name, + status: "ok", + durationMs: Date.now() - startTime, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + + task.lastRun = new Date(); + task.lastStatus = "error"; + task.lastError = errorMsg; + + audit.cron({ + jobId: task.id, + jobName: task.name, + status: "error", + error: errorMsg, + durationMs: Date.now() - startTime, + }); + + // Notify about error + const message = `**Scheduled Task Failed: ${task.name}**\n\nError: ${errorMsg}`; + for (const userId of config.telegram.allowedUsers) { + await sendToUser(telegramBot, userId, message); + } + } + } + + function scheduleTask(task: ScheduledTask): void { + // Remove existing job if any + const existing = cronJobs.get(task.id); + if (existing) { + existing.stop(); + cronJobs.delete(task.id); + } + + if (!task.enabled || !config.scheduler.enabled) { + return; + } + + try { + const job = new CronJob( + task.schedule, + () => { + void executeTask(task); + }, + null, + true, // Start immediately + undefined, // Default timezone + undefined, + false // Don't run on init + ); + cronJobs.set(task.id, job); + } catch (err) { + console.error(`[scheduler] Failed to schedule task ${task.id}:`, err); + } + } + + return { + addTask(taskInput: Omit): string { + const id = generateId(); + const task: ScheduledTask = { ...taskInput, id }; + tasks.set(id, task); + scheduleTask(task); + return id; + }, + + removeTask(id: string): boolean { + const task = tasks.get(id); + if (!task) return false; + + const job = cronJobs.get(id); + if (job) { + job.stop(); + cronJobs.delete(id); + } + + tasks.delete(id); + return true; + }, + + enableTask(id: string, enabled: boolean): boolean { + const task = tasks.get(id); + if (!task) return false; + + task.enabled = enabled; + scheduleTask(task); + return true; + }, + + listTasks(): ScheduledTask[] { + return Array.from(tasks.values()); + }, + + async runTask(id: string): Promise { + const task = tasks.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + await executeTask(task); + }, + + start(): void { + if (!config.scheduler.enabled) { + console.log("[scheduler] Scheduler is disabled"); + return; + } + + console.log("[scheduler] Starting scheduler..."); + for (const task of tasks.values()) { + scheduleTask(task); + } + }, + + stop(): void { + console.log("[scheduler] Stopping scheduler..."); + for (const job of cronJobs.values()) { + job.stop(); + } + cronJobs.clear(); + }, + }; +} + +/** + * Parse schedule from human-readable format + */ +export function parseSchedule(input: string): string | null { + const lower = input.toLowerCase().trim(); + + // Common patterns + const patterns: Record = { + "every minute": "* * * * *", + "every 5 minutes": "*/5 * * * *", + "every 15 minutes": "*/15 * * * *", + "every 30 minutes": "*/30 * * * *", + "every hour": "0 * * * *", + hourly: "0 * * * *", + "every day": "0 9 * * *", + daily: "0 9 * * *", + "every morning": "0 9 * * *", + "every evening": "0 18 * * *", + "every week": "0 9 * * 1", + weekly: "0 9 * * 1", + "every monday": "0 9 * * 1", + "every tuesday": "0 9 * * 2", + "every wednesday": "0 9 * * 3", + "every thursday": "0 9 * * 4", + "every friday": "0 9 * * 5", + "every saturday": "0 9 * * 6", + "every sunday": "0 9 * * 0", + }; + + if (patterns[lower]) { + return patterns[lower]; + } + + // Check if it's already a valid cron expression (5 or 6 fields) + const parts = input.trim().split(/\s+/); + if (parts.length >= 5 && parts.length <= 6) { + return input.trim(); + } + + return null; +} + +/** + * Format next run time + */ +export function formatNextRun(cronExpression: string): string { + try { + const job = new CronJob(cronExpression, () => {}); + const nextDate = job.nextDate(); + return nextDate.toLocaleString(); + } catch { + return "Invalid schedule"; + } +} + +/** + * Built-in task templates + */ +export const taskTemplates = { + morningBriefing: { + name: "Morning Briefing", + schedule: "0 9 * * *", // 9 AM daily + prompt: "Give me a brief morning update. Include: current date, a motivational quote, and remind me to check my priorities for the day.", + }, + weeklyReview: { + name: "Weekly Review", + schedule: "0 17 * * 5", // 5 PM on Fridays + prompt: "It's Friday. Help me reflect on the week. What should I consider for my weekly review?", + }, + healthReminder: { + name: "Health Reminder", + schedule: "0 */2 * * *", // Every 2 hours + prompt: "Give me a brief health reminder (stretch, drink water, take a break). Keep it under 2 sentences.", + }, +}; diff --git a/secure/telegram.ts b/secure/telegram.ts new file mode 100644 index 000000000..af3b676c8 --- /dev/null +++ b/secure/telegram.ts @@ -0,0 +1,321 @@ +/** + * Moltbot Secure - Telegram Channel + * + * Minimal, secure Telegram bot handler. + * Allowlist-only: only approved users can interact. + */ + +import { Bot, Context, webhookCallback } from "grammy"; +import type { SecureConfig } from "./config.js"; +import type { AuditLogger } from "./audit.js"; +import type { AgentCore, ConversationStore, Message } from "./agent.js"; + +export type TelegramBot = { + bot: Bot; + start: () => Promise; + stop: () => Promise; + webhookHandler: (path?: string) => ReturnType; +}; + +export type TelegramDeps = { + config: SecureConfig; + audit: AuditLogger; + agent: AgentCore; + conversations: ConversationStore; + onWebhookMessage?: (userId: number, text: string) => void; +}; + +function isUserAllowed(userId: number, allowedUsers: number[]): boolean { + return allowedUsers.includes(userId); +} + +function formatUsername(ctx: Context): string { + const user = ctx.from; + if (!user) return "unknown"; + if (user.username) return `@${user.username}`; + const name = [user.first_name, user.last_name].filter(Boolean).join(" "); + return name || `id:${user.id}`; +} + +export function createTelegramBot(deps: TelegramDeps): TelegramBot { + const { config, audit, agent, conversations } = deps; + const bot = new Bot(config.telegram.botToken); + + // Error handler + bot.catch((err) => { + audit.error({ + error: `Telegram bot error: ${err.message}`, + metadata: { stack: err.stack }, + }); + }); + + // Command: /start + bot.command("start", async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + audit.messageBlocked({ + userId: userId || 0, + username: formatUsername(ctx), + reason: "User not in allowlist", + }); + await ctx.reply("Access denied. You are not authorized to use this bot."); + return; + } + + await ctx.reply( + `Welcome to Moltbot Secure. + +You are authorized to use this bot. + +Commands: +/start - Show this message +/clear - Clear conversation history +/status - Check bot status +/help - Show help + +Just send me a message to chat!` + ); + }); + + // Command: /clear + bot.command("clear", async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + return; + } + + conversations.clear(userId); + await ctx.reply("Conversation history cleared."); + }); + + // Command: /status + bot.command("status", async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + return; + } + + const history = conversations.get(userId); + await ctx.reply( + `Status: +- AI Provider: ${agent.provider} +- Conversation: ${history.length} messages +- Sandbox: ${config.sandbox.enabled ? "enabled" : "disabled"} +- Webhooks: ${config.webhooks.enabled ? "enabled" : "disabled"} +- Scheduler: ${config.scheduler.enabled ? "enabled" : "disabled"}` + ); + }); + + // Command: /help + bot.command("help", async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + return; + } + + await ctx.reply( + `Moltbot Secure Help + +This is a secure, self-hosted AI assistant. + +Features: +- Chat with AI (text messages) +- Forward content for analysis +- Receive webhook notifications + +Commands: +/start - Welcome message +/clear - Clear conversation history +/status - Bot status +/help - This message + +Security: +- Only authorized users can interact +- All interactions are logged +- No data is sent to third parties (except AI provider)` + ); + }); + + // Handle all text messages + bot.on("message:text", async (ctx) => { + const userId = ctx.from?.id; + const username = formatUsername(ctx); + const text = ctx.message.text; + + if (!userId) return; + + // Check allowlist + if (!isUserAllowed(userId, config.telegram.allowedUsers)) { + audit.messageBlocked({ + userId, + username, + reason: "User not in allowlist", + }); + await ctx.reply("Access denied. You are not authorized to use this bot."); + return; + } + + // Skip commands (handled above) + if (text.startsWith("/")) return; + + const startTime = Date.now(); + + try { + // Show typing indicator + await ctx.replyWithChatAction("typing"); + + // Add user message to history + conversations.add(userId, { role: "user", content: text }); + + // Get conversation history + const history = conversations.get(userId); + + // Call AI + const response = await agent.chat(history); + + // Add assistant response to history + conversations.add(userId, { role: "assistant", content: response.text }); + + // Send response + await ctx.reply(response.text, { parse_mode: "Markdown" }).catch(async () => { + // Fallback without markdown if it fails + await ctx.reply(response.text); + }); + + // Audit log + audit.message({ + userId, + username, + text, + response: response.text, + durationMs: Date.now() - startTime, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + audit.error({ + error: `Failed to process message: ${errorMsg}`, + metadata: { userId, username }, + }); + + await ctx.reply("Sorry, I encountered an error processing your message. Please try again."); + } + }); + + // Handle forwarded messages + bot.on("message:forward_origin", async (ctx) => { + const userId = ctx.from?.id; + const username = formatUsername(ctx); + + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + audit.messageBlocked({ + userId: userId || 0, + username, + reason: "User not in allowlist", + }); + return; + } + + const text = ctx.message.text || ctx.message.caption || ""; + if (!text) { + await ctx.reply("I received your forwarded message but couldn't extract any text."); + return; + } + + const startTime = Date.now(); + + try { + await ctx.replyWithChatAction("typing"); + + // Process as a standalone analysis (don't add to conversation history) + const response = await agent.chat([ + { + role: "user", + content: `Please analyze this forwarded message:\n\n${text}`, + }, + ]); + + await ctx.reply(response.text, { parse_mode: "Markdown" }).catch(async () => { + await ctx.reply(response.text); + }); + + audit.message({ + userId, + username, + text: `[FORWARDED] ${text}`, + response: response.text, + durationMs: Date.now() - startTime, + }); + } catch (err) { + audit.error({ + error: `Failed to process forwarded message: ${err instanceof Error ? err.message : String(err)}`, + }); + await ctx.reply("Sorry, I couldn't analyze that forwarded message."); + } + }); + + // Handle photos + bot.on("message:photo", async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + return; + } + + await ctx.reply( + "I received your image. Image analysis is available with Claude - please describe what you'd like me to analyze." + ); + }); + + // Handle documents + bot.on("message:document", async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) { + return; + } + + await ctx.reply( + "I received your document. Document analysis coming soon - for now, please copy/paste the text content." + ); + }); + + return { + bot, + + async start(): Promise { + console.log("[telegram] Starting bot in polling mode..."); + await bot.start({ + onStart: (botInfo) => { + console.log(`[telegram] Bot started: @${botInfo.username}`); + }, + }); + }, + + async stop(): Promise { + console.log("[telegram] Stopping bot..."); + await bot.stop(); + }, + + webhookHandler(path = "/telegram"): ReturnType { + return webhookCallback(bot, "http", { path }); + }, + }; +} + +/** + * Send a message to a user (for webhook notifications, cron results, etc.) + */ +export async function sendToUser( + bot: Bot, + userId: number, + message: string +): Promise { + try { + await bot.api.sendMessage(userId, message, { parse_mode: "Markdown" }).catch(async () => { + // Fallback without markdown + await bot.api.sendMessage(userId, message); + }); + return true; + } catch (err) { + console.error(`[telegram] Failed to send message to ${userId}:`, err); + return false; + } +} diff --git a/secure/tsconfig.json b/secure/tsconfig.json new file mode 100644 index 000000000..ed701170b --- /dev/null +++ b/secure/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/secure/webhooks.ts b/secure/webhooks.ts new file mode 100644 index 000000000..430d0e50d --- /dev/null +++ b/secure/webhooks.ts @@ -0,0 +1,287 @@ +/** + * Moltbot Secure - Webhook Receiver + * + * Authenticated webhook endpoint for external integrations. + * Receives events from GitHub, Stripe, uptime monitors, etc. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { timingSafeEqual } from "node:crypto"; +import type { SecureConfig } from "./config.js"; +import type { AuditLogger } from "./audit.js"; +import type { AgentCore } from "./agent.js"; +import type { Bot } from "grammy"; +import { sendToUser } from "./telegram.js"; + +export type WebhookHandler = { + handleRequest: (req: IncomingMessage, res: ServerResponse) => Promise; +}; + +export type WebhookDeps = { + config: SecureConfig; + audit: AuditLogger; + agent: AgentCore; + telegramBot: Bot; +}; + +/** + * Timing-safe token comparison + */ +function verifyToken(provided: string, expected: string): boolean { + if (!provided || !expected) return false; + if (provided.length !== expected.length) return false; + + try { + return timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + } catch { + return false; + } +} + +/** + * Extract token from request + */ +function extractToken(req: IncomingMessage, url: URL): { token: string; fromQuery: boolean } { + // Check Authorization header (preferred) + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return { token: authHeader.slice(7), fromQuery: false }; + } + + // Check X-Moltbot-Token header + const tokenHeader = req.headers["x-moltbot-token"]; + if (typeof tokenHeader === "string") { + return { token: tokenHeader, fromQuery: false }; + } + + // Check query parameter (deprecated, less secure) + const queryToken = url.searchParams.get("token"); + if (queryToken) { + return { token: queryToken, fromQuery: true }; + } + + return { token: "", fromQuery: false }; +} + +/** + * Read JSON body from request + */ +async function readJsonBody( + req: IncomingMessage, + maxBytes = 1024 * 1024 // 1MB default +): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let size = 0; + + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + req.destroy(); + resolve({ ok: false, error: "payload too large" }); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + try { + const body = Buffer.concat(chunks).toString("utf-8"); + if (!body.trim()) { + resolve({ ok: true, value: {} }); + return; + } + const parsed = JSON.parse(body); + resolve({ ok: true, value: parsed }); + } catch { + resolve({ ok: false, error: "invalid JSON" }); + } + }); + + req.on("error", () => { + resolve({ ok: false, error: "read error" }); + }); + }); +} + +/** + * Send JSON response + */ +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +/** + * Summarize webhook payload using AI + */ +async function summarizeWebhook( + agent: AgentCore, + source: string, + payload: unknown +): Promise { + const payloadStr = JSON.stringify(payload, null, 2).slice(0, 4000); + + try { + const response = await agent.chat([ + { + role: "user", + content: `Summarize this webhook notification from "${source}" in 2-3 concise sentences. Focus on what happened and any action needed:\n\n${payloadStr}`, + }, + ]); + return response.text; + } catch { + return `Received webhook from ${source}. (Unable to summarize)`; + } +} + +export function createWebhookHandler(deps: WebhookDeps): WebhookHandler { + const { config, audit, agent, telegramBot } = deps; + const { basePath, secret, enabled } = config.webhooks; + + return { + async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (!enabled) return false; + + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + + // Check if this is a webhook path + if (!url.pathname.startsWith(basePath)) { + return false; + } + + const startTime = Date.now(); + const subPath = url.pathname.slice(basePath.length).replace(/^\//, "") || "default"; + + // Verify authentication + const { token, fromQuery } = extractToken(req, url); + + if (!verifyToken(token, secret)) { + audit.webhookBlocked({ + path: url.pathname, + reason: "Invalid or missing token", + }); + sendJson(res, 401, { ok: false, error: "Unauthorized" }); + return true; + } + + if (fromQuery) { + console.warn( + "[webhooks] Token provided via query parameter is insecure. Use Authorization header instead." + ); + } + + // Only accept POST + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.end("Method Not Allowed"); + return true; + } + + // Read body + const body = await readJsonBody(req); + if (!body.ok) { + sendJson(res, body.error === "payload too large" ? 413 : 400, { + ok: false, + error: body.error, + }); + return true; + } + + // Process webhook + try { + // Summarize with AI + const summary = await summarizeWebhook(agent, subPath, body.value); + + // Notify all allowed users + const notificationText = `**Webhook: ${subPath}**\n\n${summary}`; + + for (const userId of config.telegram.allowedUsers) { + await sendToUser(telegramBot, userId, notificationText); + } + + audit.webhook({ + path: url.pathname, + status: 200, + durationMs: Date.now() - startTime, + }); + + sendJson(res, 200, { ok: true, processed: true }); + } catch (err) { + audit.error({ + error: `Webhook processing failed: ${err instanceof Error ? err.message : String(err)}`, + metadata: { path: url.pathname }, + }); + + sendJson(res, 500, { ok: false, error: "Processing failed" }); + } + + return true; + }, + }; +} + +/** + * Built-in webhook handlers for common services + */ +export const webhookParsers = { + /** + * Parse GitHub webhook + */ + github(payload: unknown): string { + const p = payload as Record; + const action = p.action as string | undefined; + const repo = (p.repository as Record)?.full_name as string | undefined; + + if (p.pull_request) { + const pr = p.pull_request as Record; + return `GitHub PR ${action}: ${pr.title} in ${repo}`; + } + + if (p.issue) { + const issue = p.issue as Record; + return `GitHub Issue ${action}: ${issue.title} in ${repo}`; + } + + if (p.pusher) { + const commits = p.commits as unknown[] | undefined; + return `GitHub Push: ${commits?.length || 0} commits to ${repo}`; + } + + return `GitHub event in ${repo || "unknown"}`; + }, + + /** + * Parse Stripe webhook + */ + stripe(payload: unknown): string { + const p = payload as Record; + const type = p.type as string | undefined; + const data = p.data as Record | undefined; + const object = data?.object as Record | undefined; + + if (type?.startsWith("payment_intent.")) { + const amount = object?.amount as number | undefined; + const currency = object?.currency as string | undefined; + return `Stripe ${type}: ${amount ? (amount / 100).toFixed(2) : "?"} ${currency?.toUpperCase() || ""}`; + } + + if (type?.startsWith("customer.")) { + return `Stripe ${type}`; + } + + return `Stripe event: ${type || "unknown"}`; + }, + + /** + * Parse generic uptime monitor webhook + */ + uptime(payload: unknown): string { + const p = payload as Record; + const status = p.status || p.state || p.alert_type; + const url = p.url || p.monitor_url || p.target; + return `Uptime alert: ${status} for ${url || "unknown"}`; + }, +};