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:
committed by
Peter Steinberger
parent
f648aae440
commit
6bd6ae41b1
@@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user