Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions src/lib/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,6 +58,20 @@ interface LoadedPlugin {
const loadedPlugins: LoadedPlugin[] = [];
const eventHandlers = new Map<string, Array<(event: PluginEvent) => void>>();

async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeoutHandle: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, 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.
*/
Expand All @@ -80,6 +98,11 @@ function createPluginContext(pluginName: string): PluginContext {
* - hooks: Hooks (optional)
*/
export async function loadPlugins(): Promise<void> {
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)) {
Expand Down Expand Up @@ -119,7 +142,11 @@ export async function loadPlugins(): Promise<void> {
// 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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
17 changes: 14 additions & 3 deletions src/queue-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -53,6 +53,15 @@ const conversations = new Map<string, Conversation>();

const MAX_CONVERSATION_MESSAGES = 50;

function sanitizeResponseMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const allowed: Record<string, unknown> = {};
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'))) {
Expand Down Expand Up @@ -181,6 +190,7 @@ async function completeConversation(conv: Conversation): Promise<void> {

// 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 = {
Expand All @@ -191,7 +201,7 @@ async function completeConversation(conv: Conversation): Promise<void> {
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'
Expand Down Expand Up @@ -359,6 +369,7 @@ async function processMessage(messageFile: string): Promise<void> {

// Run outgoing hooks
const { text: hookedResponse, metadata } = await runOutgoingHooks(finalResponse, { channel, sender, messageId, originalMessage: rawMessage });
const safeMetadata = sanitizeResponseMetadata(metadata);

const responseData: ResponseData = {
channel,
Expand All @@ -369,7 +380,7 @@ async function processMessage(messageFile: string): Promise<void> {
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'
Expand Down