fix: address code review findings for plugin commands

- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Glucksberg
2026-01-23 17:00:33 +00:00
committed by Peter Steinberger
parent f648aae440
commit 6bd6ae41b1
2 changed files with 55 additions and 16 deletions

View File

@@ -23,7 +23,7 @@ export const handlePluginCommand: CommandHandler = async (
const match = matchPluginCommand(command.commandBodyNormalized); const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null; if (!match) return null;
// Execute the plugin command // Execute the plugin command (always returns a result)
const result = await executePluginCommand({ const result = await executePluginCommand({
command: match.command, command: match.command,
args: match.args, args: match.args,
@@ -34,13 +34,8 @@ export const handlePluginCommand: CommandHandler = async (
config: cfg, config: cfg,
}); });
if (result) { return {
return { shouldContinue: false,
shouldContinue: false, reply: { text: result.text },
reply: { text: result.text }, };
};
}
// Command was blocked (e.g., unauthorized) - don't continue to agent
return { shouldContinue: false };
}; };

View File

@@ -16,6 +16,12 @@ type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
// Registry of plugin commands // Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map(); const pluginCommands: Map<string, RegisteredPluginCommand> = 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. * Reserved command names that plugins cannot override.
* These are built-in commands from commands-registry.data.ts. * These are built-in commands from commands-registry.data.ts.
@@ -70,7 +76,8 @@ export function validateCommandName(name: string): string | null {
} }
// Must start with a letter, contain only letters, numbers, hyphens, underscores // Must start with a letter, contain only letters, numbers, hyphens, underscores
if (!/^[a-z][a-z0-9_-]*$/i.test(trimmed)) { // 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"; return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores";
} }
@@ -95,6 +102,16 @@ export function registerPluginCommand(
pluginId: string, pluginId: string,
command: ClawdbotPluginCommandDefinition, command: ClawdbotPluginCommandDefinition,
): CommandRegistrationResult { ): 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); const validationError = validateCommandName(command.name);
if (validationError) { if (validationError) {
return { ok: false, error: validationError }; return { ok: false, error: validationError };
@@ -165,8 +182,27 @@ export function matchPluginCommand(
return { command, args: args || undefined }; 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)
return args.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
}
/** /**
* Execute a plugin command handler. * 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: { export async function executePluginCommand(params: {
command: RegisteredPluginCommand; command: RegisteredPluginCommand;
@@ -176,9 +212,8 @@ export async function executePluginCommand(params: {
isAuthorizedSender: boolean; isAuthorizedSender: boolean;
commandBody: string; commandBody: string;
config: ClawdbotConfig; config: ClawdbotConfig;
}): Promise<{ text: string } | null> { }): Promise<{ text: string }> {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
params;
// Check authorization // Check authorization
const requireAuth = command.requireAuth !== false; // Default to true const requireAuth = command.requireAuth !== false; // Default to true
@@ -186,27 +221,36 @@ export async function executePluginCommand(params: {
logVerbose( logVerbose(
`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`, `Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`,
); );
// Return a message instead of silently ignoring
return { text: "⚠️ This command requires authorization." }; return { text: "⚠️ This command requires authorization." };
} }
// Sanitize args before passing to handler
const sanitizedArgs = sanitizeArgs(args);
const ctx: PluginCommandContext = { const ctx: PluginCommandContext = {
senderId, senderId,
channel, channel,
isAuthorizedSender, isAuthorizedSender,
args, args: sanitizedArgs,
commandBody, commandBody,
config, config,
}; };
// Lock registry during execution to prevent concurrent modifications
registryLocked = true;
try { try {
const result = await command.handler(ctx); const result = await command.handler(ctx);
logVerbose(
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
);
return { text: result.text }; return { text: result.text };
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
logVerbose(`Plugin command /${command.name} error: ${error.message}`); logVerbose(`Plugin command /${command.name} error: ${error.message}`);
// Don't leak internal error details - return a safe generic message // Don't leak internal error details - return a safe generic message
return { text: "⚠️ Command failed. Please try again later." }; return { text: "⚠️ Command failed. Please try again later." };
} finally {
registryLocked = false;
} }
} }