diff --git a/README.md b/README.md index ed0c76c..bb0b6b1 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,20 @@ These commands work in Discord, Telegram, and WhatsApp: **Access control note:** before routing, channel clients apply sender pairing allowlist checks. +## 🔌 Plugin Security + +TinyClaw can load local plugins from `~/.tinyclaw/plugins`, but plugins are **disabled by default**. + +- Enable plugins: `TINYCLAW_PLUGINS_ENABLED=1` +- Hook timeout (ms): `TINYCLAW_PLUGIN_HOOK_TIMEOUT_MS` (default `1500`) +- Activate timeout (ms): `TINYCLAW_PLUGIN_ACTIVATE_TIMEOUT_MS` (default `3000`) + +Security model: + +- Plugins are fully trusted local code. +- Do not install plugins from untrusted sources. +- Plugin code runs with the same permissions as the TinyClaw process. + ## 🤖 Using Agents ### Routing Messages diff --git a/src/lib/plugins.ts b/src/lib/plugins.ts index 27a7bd2..9e82a6d 100644 --- a/src/lib/plugins.ts +++ b/src/lib/plugins.ts @@ -10,6 +10,10 @@ import path from 'path'; import { TINYCLAW_HOME } from './config'; import { log } from './logging'; +const PLUGINS_ENABLED = process.env.TINYCLAW_PLUGINS_ENABLED === '1'; +const PLUGIN_HOOK_TIMEOUT_MS = Number(process.env.TINYCLAW_PLUGIN_HOOK_TIMEOUT_MS || 1500); +const PLUGIN_ACTIVATE_TIMEOUT_MS = Number(process.env.TINYCLAW_PLUGIN_ACTIVATE_TIMEOUT_MS || 3000); + // Types export interface PluginEvent { type: string; @@ -54,6 +58,20 @@ interface LoadedPlugin { const loadedPlugins: LoadedPlugin[] = []; const eventHandlers = new Map void>>(); +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeoutHandle: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +} + /** * Create the plugin context passed to activate() functions. */ @@ -80,6 +98,11 @@ function createPluginContext(pluginName: string): PluginContext { * - hooks: Hooks (optional) */ export async function loadPlugins(): Promise { + if (!PLUGINS_ENABLED) { + log('INFO', 'Plugins disabled (set TINYCLAW_PLUGINS_ENABLED=1 to enable)'); + return; + } + const pluginsDir = path.join(TINYCLAW_HOME, 'plugins'); if (!fs.existsSync(pluginsDir)) { @@ -119,7 +142,11 @@ export async function loadPlugins(): Promise { // Call activate() if present if (typeof pluginModule.activate === 'function') { const ctx = createPluginContext(pluginName); - await pluginModule.activate(ctx); + await withTimeout( + Promise.resolve(pluginModule.activate(ctx)), + PLUGIN_ACTIVATE_TIMEOUT_MS, + `plugin '${pluginName}' activate` + ); } // Store hooks if present @@ -146,10 +173,18 @@ export async function runOutgoingHooks(message: string, ctx: HookContext): Promi let text = message; let metadata: HookMetadata = {}; + if (!PLUGINS_ENABLED || loadedPlugins.length === 0) { + return { text, metadata }; + } + for (const plugin of loadedPlugins) { if (plugin.hooks?.transformOutgoing) { try { - const result = await plugin.hooks.transformOutgoing(text, ctx); + const result = await withTimeout( + Promise.resolve(plugin.hooks.transformOutgoing(text, ctx)), + PLUGIN_HOOK_TIMEOUT_MS, + `plugin '${plugin.name}' transformOutgoing` + ); if (typeof result === 'string') { text = result; } else { @@ -172,10 +207,18 @@ export async function runIncomingHooks(message: string, ctx: HookContext): Promi let text = message; let metadata: HookMetadata = {}; + if (!PLUGINS_ENABLED || loadedPlugins.length === 0) { + return { text, metadata }; + } + for (const plugin of loadedPlugins) { if (plugin.hooks?.transformIncoming) { try { - const result = await plugin.hooks.transformIncoming(text, ctx); + const result = await withTimeout( + Promise.resolve(plugin.hooks.transformIncoming(text, ctx)), + PLUGIN_HOOK_TIMEOUT_MS, + `plugin '${plugin.name}' transformIncoming` + ); if (typeof result === 'string') { text = result; } else { diff --git a/src/queue-processor.ts b/src/queue-processor.ts index 9af949f..d0d8684 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -25,7 +25,7 @@ import { import { log, emitEvent } from './lib/logging'; import { parseAgentRouting, findTeamForAgent, getAgentResetFlag, extractTeammateMentions } from './lib/routing'; import { invokeAgent } from './lib/invoke'; -import { loadPlugins, runIncomingHooks, runOutgoingHooks, HookMetadata } from './lib/plugins'; +import { loadPlugins, runIncomingHooks, runOutgoingHooks } from './lib/plugins'; import { jsonrepair } from 'jsonrepair'; /** Parse JSON with automatic repair for malformed content (e.g. bad escapes). */ @@ -53,6 +53,15 @@ const conversations = new Map(); const MAX_CONVERSATION_MESSAGES = 50; +function sanitizeResponseMetadata(metadata: Record): Record { + const allowed: Record = {}; + const parseMode = metadata.parseMode; + if (parseMode === 'MarkdownV2') { + allowed.parseMode = parseMode; + } + return allowed; +} + // Clean up orphaned files from processing/ on startup function recoverOrphanedFiles() { for (const f of fs.readdirSync(QUEUE_PROCESSING).filter(f => f.endsWith('.json'))) { @@ -181,6 +190,7 @@ async function completeConversation(conv: Conversation): Promise { // Run outgoing hooks const { text: hookedResponse, metadata } = await runOutgoingHooks(finalResponse, { channel: conv.channel, sender: conv.sender, messageId: conv.messageId, originalMessage: conv.originalMessage }); + const safeMetadata = sanitizeResponseMetadata(metadata); // Write to outgoing queue const responseData: ResponseData = { @@ -191,7 +201,7 @@ async function completeConversation(conv: Conversation): Promise { timestamp: Date.now(), messageId: conv.messageId, files: outboundFiles.length > 0 ? outboundFiles : undefined, - metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + metadata: Object.keys(safeMetadata).length > 0 ? safeMetadata : undefined, }; const responseFile = conv.channel === 'heartbeat' @@ -359,6 +369,7 @@ async function processMessage(messageFile: string): Promise { // Run outgoing hooks const { text: hookedResponse, metadata } = await runOutgoingHooks(finalResponse, { channel, sender, messageId, originalMessage: rawMessage }); + const safeMetadata = sanitizeResponseMetadata(metadata); const responseData: ResponseData = { channel, @@ -369,7 +380,7 @@ async function processMessage(messageFile: string): Promise { messageId, agent: agentId, files: outboundFiles.length > 0 ? outboundFiles : undefined, - metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + metadata: Object.keys(safeMetadata).length > 0 ? safeMetadata : undefined, }; const responseFile = channel === 'heartbeat'