/** * Plugin Command Registry * * Manages commands registered by plugins that bypass the LLM agent. * These commands are processed before built-in commands and before agent invocation. */ import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotPluginCommandDefinition, PluginCommandContext, PluginCommandResult, } from "./types.js"; import { logVerbose } from "../globals.js"; type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & { pluginId: string; }; // Registry of plugin commands const pluginCommands: Map = new Map(); // Lock to prevent modifications during command execution let registryLocked = false; // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; /** * Reserved command names that plugins cannot override. * These are built-in commands from commands-registry.data.ts. */ const RESERVED_COMMANDS = new Set([ // Core commands "help", "commands", "status", "whoami", "context", // Session management "stop", "restart", "reset", "new", "compact", // Configuration "config", "debug", "allowlist", "activation", // Agent control "skill", "subagents", "model", "models", "queue", // Messaging "send", // Execution "bash", "exec", // Mode toggles "think", "verbose", "reasoning", "elevated", // Billing "usage", ]); /** * Validate a command name. * Returns an error message if invalid, or null if valid. */ export function validateCommandName(name: string): string | null { const trimmed = name.trim().toLowerCase(); if (!trimmed) { return "Command name cannot be empty"; } // Must start with a letter, contain only letters, numbers, hyphens, underscores // Note: trimmed is already lowercased, so no need for /i flag if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; } // Check reserved commands if (RESERVED_COMMANDS.has(trimmed)) { return `Command name "${trimmed}" is reserved by a built-in command`; } return null; } export type CommandRegistrationResult = { ok: boolean; error?: string; }; /** * Register a plugin command. * Returns an error if the command name is invalid or reserved. */ export function registerPluginCommand( pluginId: string, command: ClawdbotPluginCommandDefinition, ): CommandRegistrationResult { // Prevent registration while commands are being processed if (registryLocked) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } // Validate handler is a function if (typeof command.handler !== "function") { return { ok: false, error: "Command handler must be a function" }; } const validationError = validateCommandName(command.name); if (validationError) { return { ok: false, error: validationError }; } const key = `/${command.name.toLowerCase()}`; // Check for duplicate registration if (pluginCommands.has(key)) { const existing = pluginCommands.get(key)!; return { ok: false, error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`, }; } pluginCommands.set(key, { ...command, pluginId }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; } /** * Clear all registered plugin commands. * Called during plugin reload. */ export function clearPluginCommands(): void { pluginCommands.clear(); } /** * Clear plugin commands for a specific plugin. */ export function clearPluginCommandsForPlugin(pluginId: string): void { for (const [key, cmd] of pluginCommands.entries()) { if (cmd.pluginId === pluginId) { pluginCommands.delete(key); } } } /** * Check if a command body matches a registered plugin command. * Returns the command definition and parsed args if matched. * * Note: If a command has `acceptsArgs: false` and the user provides arguments, * the command will not match. This allows the message to fall through to * built-in handlers or the agent. Document this behavior to plugin authors. */ export function matchPluginCommand( commandBody: string, ): { command: RegisteredPluginCommand; args?: string } | null { const trimmed = commandBody.trim(); if (!trimmed.startsWith("/")) return null; // Extract command name and args const spaceIndex = trimmed.indexOf(" "); const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); const key = commandName.toLowerCase(); const command = pluginCommands.get(key); if (!command) return null; // If command doesn't accept args but args were provided, don't match if (args && !command.acceptsArgs) return null; return { command, args: args || undefined }; } /** * Sanitize command arguments to prevent injection attacks. * Removes control characters and enforces length limits. */ function sanitizeArgs(args: string | undefined): string | undefined { if (!args) return undefined; // Enforce length limit if (args.length > MAX_ARGS_LENGTH) { return args.slice(0, MAX_ARGS_LENGTH); } // Remove control characters (except newlines and tabs which may be intentional) let sanitized = ""; for (const char of args) { const code = char.charCodeAt(0); const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f; if (!isControl) sanitized += char; } return sanitized; } /** * Execute a plugin command handler. * * Note: Plugin authors should still validate and sanitize ctx.args for their * specific use case. This function provides basic defense-in-depth sanitization. */ export async function executePluginCommand(params: { command: RegisteredPluginCommand; args?: string; senderId?: string; channel: string; isAuthorizedSender: boolean; commandBody: string; config: ClawdbotConfig; }): Promise { const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; // Check authorization const requireAuth = command.requireAuth !== false; // Default to true if (requireAuth && !isAuthorizedSender) { logVerbose( `Plugin command /${command.name} blocked: unauthorized sender ${senderId || ""}`, ); return { text: "⚠️ This command requires authorization." }; } // Sanitize args before passing to handler const sanitizedArgs = sanitizeArgs(args); const ctx: PluginCommandContext = { senderId, channel, isAuthorizedSender, args: sanitizedArgs, commandBody, config, }; // Lock registry during execution to prevent concurrent modifications registryLocked = true; try { const result = await command.handler(ctx); logVerbose( `Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`, ); return result; } catch (err) { const error = err as Error; logVerbose(`Plugin command /${command.name} error: ${error.message}`); // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { registryLocked = false; } } /** * List all registered plugin commands. * Used for /help and /commands output. */ export function listPluginCommands(): Array<{ name: string; description: string; pluginId: string; }> { return Array.from(pluginCommands.values()).map((cmd) => ({ name: cmd.name, description: cmd.description, pluginId: cmd.pluginId, })); } /** * Get plugin command specs for native command registration (e.g., Telegram). */ export function getPluginCommandSpecs(): Array<{ name: string; description: string; }> { return Array.from(pluginCommands.values()).map((cmd) => ({ name: cmd.name, description: cmd.description, })); }