fix: harden plugin commands (#1558) (thanks @Glucksberg)

This commit is contained in:
Peter Steinberger 2026-01-24 05:08:36 +00:00
parent 051c92651f
commit bfc0fb742e
8 changed files with 197 additions and 56 deletions

View File

@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). - Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07. - Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. - CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. - CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.

View File

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { handlePluginCommand } from "./commands-plugin.js";
describe("handlePluginCommand", () => {
beforeEach(() => {
clearPluginCommands();
});
it("skips plugin commands when text commands are disabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, false);
expect(result).toBeNull();
});
it("executes plugin commands when text commands are enabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, true);
expect(result?.reply?.text).toBe("pong");
});
});

View File

@ -15,8 +15,9 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
*/ */
export const handlePluginCommand: CommandHandler = async ( export const handlePluginCommand: CommandHandler = async (
params, params,
_allowTextCommands, allowTextCommands,
): Promise<CommandHandlerResult | null> => { ): Promise<CommandHandlerResult | null> => {
if (!allowTextCommands) return null;
const { command, cfg } = params; const { command, cfg } = params;
// Try to match a plugin command // Try to match a plugin command

View File

@ -4,9 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js"; import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js"; import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
afterEach(() => { afterEach(() => {
clearPluginCommands();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -423,6 +425,19 @@ describe("buildCommandsMessage", () => {
); );
expect(text).toContain("/demo_skill - Demo skill"); expect(text).toContain("/demo_skill - Demo skill");
}); });
it("includes plugin commands when registered", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("🔌 Plugin commands");
expect(text).toContain("/plugstatus - Plugin status");
});
}); });
describe("buildHelpMessage", () => { describe("buildHelpMessage", () => {

View File

@ -22,6 +22,7 @@ import {
} from "../utils/usage-format.js"; } from "../utils/usage-format.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js"; import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js"; import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@ -442,5 +443,12 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : ""; const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
} }
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("🔌 Plugin commands");
for (const command of pluginCommands) {
lines.push(`/${command.name} - ${command.description}`);
}
}
return lines.join("\n"); return lines.join("\n");
} }

View File

@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
clearPluginCommands,
executePluginCommand,
matchPluginCommand,
registerPluginCommand,
validateCommandName,
} from "./commands.js";
describe("validateCommandName", () => {
it("rejects reserved aliases from built-in commands", () => {
const error = validateCommandName("id");
expect(error).toContain("reserved");
});
});
describe("plugin command registry", () => {
beforeEach(() => {
clearPluginCommands();
});
it("normalizes command names for registration and matching", () => {
const result = registerPluginCommand("plugin-core", {
name: " ping ",
description: "Ping",
handler: () => ({ text: "pong" }),
});
expect(result.ok).toBe(true);
const match = matchPluginCommand("/ping");
expect(match?.command.name).toBe("ping");
});
it("blocks registration while a command is executing", async () => {
let nestedResult: { ok: boolean; error?: string } | undefined;
registerPluginCommand("plugin-core", {
name: "outer",
description: "Outer",
handler: () => {
nestedResult = registerPluginCommand("plugin-inner", {
name: "inner",
description: "Inner",
handler: () => ({ text: "ok" }),
});
return { text: "done" };
},
});
await executePluginCommand({
command: matchPluginCommand("/outer")!.command,
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
commandBody: "/outer",
config: {} as ClawdbotConfig,
});
expect(nestedResult?.ok).toBe(false);
expect(nestedResult?.error).toContain("processing is in progress");
});
});

View File

@ -6,6 +6,7 @@
*/ */
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { listChatCommands } from "../auto-reply/commands-registry.js";
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js"; import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
@ -16,53 +17,33 @@ 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 // Lock counter to prevent modifications during command execution
let registryLocked = false; let registryLockCount = 0;
// Maximum allowed length for command arguments (defense in depth) // Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096; const MAX_ARGS_LENGTH = 4096;
/** let cachedReservedCommands: Set<string> | null = null;
* Reserved command names that plugins cannot override.
* These are built-in commands from commands-registry.data.ts. function getReservedCommands(): Set<string> {
*/ if (cachedReservedCommands) return cachedReservedCommands;
const RESERVED_COMMANDS = new Set([ const reserved = new Set<string>();
// Core commands for (const command of listChatCommands()) {
"help", if (command.nativeName) {
"commands", const normalized = command.nativeName.trim().toLowerCase();
"status", if (normalized) reserved.add(normalized);
"whoami", }
"context", for (const alias of command.textAliases ?? []) {
// Session management const trimmed = alias.trim();
"stop", if (!trimmed) continue;
"restart", const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
"reset", const normalized = withoutSlash.trim().toLowerCase();
"new", if (normalized) reserved.add(normalized);
"compact", }
// Configuration }
"config", cachedReservedCommands = reserved;
"debug", return reserved;
"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. * Validate a command name.
@ -82,7 +63,7 @@ export function validateCommandName(name: string): string | null {
} }
// Check reserved commands // Check reserved commands
if (RESERVED_COMMANDS.has(trimmed)) { if (getReservedCommands().has(trimmed)) {
return `Command name "${trimmed}" is reserved by a built-in command`; return `Command name "${trimmed}" is reserved by a built-in command`;
} }
@ -103,7 +84,7 @@ export function registerPluginCommand(
command: ClawdbotPluginCommandDefinition, command: ClawdbotPluginCommandDefinition,
): CommandRegistrationResult { ): CommandRegistrationResult {
// Prevent registration while commands are being processed // Prevent registration while commands are being processed
if (registryLocked) { if (registryLockCount > 0) {
return { ok: false, error: "Cannot register commands while processing is in progress" }; return { ok: false, error: "Cannot register commands while processing is in progress" };
} }
@ -117,7 +98,8 @@ export function registerPluginCommand(
return { ok: false, error: validationError }; return { ok: false, error: validationError };
} }
const key = `/${command.name.toLowerCase()}`; const normalizedName = command.name.trim();
const key = `/${normalizedName.toLowerCase()}`;
// Check for duplicate registration // Check for duplicate registration
if (pluginCommands.has(key)) { if (pluginCommands.has(key)) {
@ -128,7 +110,7 @@ export function registerPluginCommand(
}; };
} }
pluginCommands.set(key, { ...command, pluginId }); pluginCommands.set(key, { ...command, name: normalizedName, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true }; return { ok: true };
} }
@ -139,6 +121,7 @@ export function registerPluginCommand(
*/ */
export function clearPluginCommands(): void { export function clearPluginCommands(): void {
pluginCommands.clear(); pluginCommands.clear();
registryLockCount = 0;
} }
/** /**
@ -190,12 +173,28 @@ function sanitizeArgs(args: string | undefined): string | undefined {
if (!args) return undefined; if (!args) return undefined;
// Enforce length limit // Enforce length limit
if (args.length > MAX_ARGS_LENGTH) { const trimmed = args.length > MAX_ARGS_LENGTH ? args.slice(0, MAX_ARGS_LENGTH) : args;
return args.slice(0, MAX_ARGS_LENGTH);
}
// Remove control characters (except newlines and tabs which may be intentional) // Remove control characters (except newlines and tabs which may be intentional)
return args.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); let needsSanitize = false;
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a) continue;
if (code < 0x20 || code === 0x7f) {
needsSanitize = true;
break;
}
}
if (!needsSanitize) return trimmed;
let sanitized = "";
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f)) {
sanitized += trimmed[i];
}
}
return sanitized;
} }
/** /**
@ -237,7 +236,7 @@ export async function executePluginCommand(params: {
}; };
// Lock registry during execution to prevent concurrent modifications // Lock registry during execution to prevent concurrent modifications
registryLocked = true; registryLockCount += 1;
try { try {
const result = await command.handler(ctx); const result = await command.handler(ctx);
logVerbose( logVerbose(
@ -250,7 +249,7 @@ export async function executePluginCommand(params: {
// 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 { } finally {
registryLocked = false; registryLockCount = Math.max(0, registryLockCount - 1);
} }
} }

View File

@ -376,7 +376,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
} }
// Register with the plugin command system (validates name and checks for duplicates) // Register with the plugin command system (validates name and checks for duplicates)
const result = registerPluginCommand(record.id, command); const normalizedCommand = { ...command, name };
const result = registerPluginCommand(record.id, normalizedCommand);
if (!result.ok) { if (!result.ok) {
pushDiagnostic({ pushDiagnostic({
level: "error", level: "error",
@ -390,7 +391,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.commands.push(name); record.commands.push(name);
registry.commands.push({ registry.commands.push({
pluginId: record.id, pluginId: record.id,
command, command: normalizedCommand,
source: record.source, source: record.source,
}); });
}; };