Security: add legacy plugin runtime exec opt-in (openclaw#20874, thanks @mbelinky)

This commit is contained in:
mbelinky
2026-02-19 12:07:35 +01:00
parent e955582c8f
commit de69f81725
8 changed files with 111 additions and 13 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Plugins: add explicit `plugins.runtime.allowLegacyExec` opt-in to re-enable deprecated `runtime.system.runCommandWithTimeout` for legacy modules while keeping runtime command execution disabled by default. (#20874) Thanks @mbelinky.
- Gateway/WebChat: block `sessions.patch` and `sessions.delete` for WebChat clients so session-store mutations stay restricted to non-WebChat operator flows. Thanks @allsmog for reporting.
- Security/Skills: for the next npm release, reject symlinks during skill packaging to prevent external file inclusion in distributed `.skill` archives. Thanks @aether-ai-agent for reporting.
- Security/iMessage: harden remote attachment SSH/SCP handling by requiring strict host-key verification, validating `channels.imessage.remoteHost` as `host`/`user@host`, and rejecting unsafe host tokens from config or auto-detection. Thanks @allsmog for reporting.

View File

@@ -267,6 +267,8 @@ export const FIELD_HELP: Record<string, string> = {
"plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.",
"plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.",
"plugins.load.paths": "Additional plugin files or directories to load.",
"plugins.runtime.allowLegacyExec":
"Opt-in compatibility switch to re-enable deprecated runtime.system.runCommandWithTimeout for legacy plugins (default: false).",
"plugins.slots": "Select which plugins own exclusive slots (memory, etc.).",
"plugins.slots.memory":
'Select the active memory plugin by id, or "none" to disable memory plugins.',

View File

@@ -306,6 +306,7 @@ export const FIELD_LABELS: Record<string, string> = {
"plugins.allow": "Plugin Allowlist",
"plugins.deny": "Plugin Denylist",
"plugins.load.paths": "Plugin Load Paths",
"plugins.runtime.allowLegacyExec": "Allow Legacy Plugin Runtime Exec",
"plugins.slots": "Plugin Slots",
"plugins.slots.memory": "Memory Plugin",
"plugins.entries": "Plugin Entries",

View File

@@ -13,6 +13,14 @@ export type PluginsLoadConfig = {
paths?: string[];
};
export type PluginsRuntimeConfig = {
/**
* Re-enable deprecated runtime.system.runCommandWithTimeout for legacy plugins.
* Disabled by default for security hardening.
*/
allowLegacyExec?: boolean;
};
export type PluginInstallRecord = {
source: "npm" | "archive" | "path";
spec?: string;
@@ -30,6 +38,7 @@ export type PluginsConfig = {
/** Optional plugin denylist (plugin ids). */
deny?: string[];
load?: PluginsLoadConfig;
runtime?: PluginsRuntimeConfig;
slots?: PluginSlotsConfig;
entries?: Record<string, PluginEntryConfig>;
installs?: Record<string, PluginInstallRecord>;

View File

@@ -623,6 +623,12 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
runtime: z
.object({
allowLegacyExec: z.boolean().optional(),
})
.strict()
.optional(),
slots: z
.object({
memory: z.string().optional(),

View File

@@ -1,13 +1,74 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
};
});
vi.mock("../../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
import { createPluginRuntime } from "./index.js";
describe("plugin runtime security hardening", () => {
it("blocks runtime.system.runCommandWithTimeout", async () => {
const blockedError =
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.";
beforeEach(() => {
loadConfigMock.mockReset();
runCommandWithTimeoutMock.mockReset();
loadConfigMock.mockReturnValue({});
});
it("blocks runtime.system.runCommandWithTimeout by default", async () => {
const runtime = createPluginRuntime();
await expect(
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
).rejects.toThrow(
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.",
);
).rejects.toThrow(blockedError);
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
});
it("allows runtime.system.runCommandWithTimeout when explicitly opted in", async () => {
loadConfigMock.mockReturnValue({
plugins: {
runtime: {
allowLegacyExec: true,
},
},
});
const commandResult = {
stdout: "hello\n",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit" as const,
};
runCommandWithTimeoutMock.mockResolvedValue(commandResult);
const runtime = createPluginRuntime();
await expect(
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
).resolves.toEqual(commandResult);
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
});
it("fails closed when config loading throws", async () => {
loadConfigMock.mockImplementation(() => {
throw new Error("config read failed");
});
const runtime = createPluginRuntime();
await expect(
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
).rejects.toThrow(blockedError);
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
});
});

View File

@@ -105,6 +105,7 @@ import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
@@ -235,12 +236,26 @@ function loadWhatsAppActions() {
return whatsappActionsPromise;
}
const runtimeCommandExecutionDisabled: PluginRuntime["system"]["runCommandWithTimeout"] =
async () => {
throw new Error(
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.",
);
};
const RUNTIME_LEGACY_EXEC_DISABLED_ERROR =
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.";
function isLegacyPluginRuntimeExecEnabled(): boolean {
try {
return loadConfig().plugins?.runtime?.allowLegacyExec === true;
} catch {
// Fail closed if config is unreadable/invalid.
return false;
}
}
const runtimeCommandExecutionGuarded: PluginRuntime["system"]["runCommandWithTimeout"] = async (
...args
) => {
if (!isLegacyPluginRuntimeExecEnabled()) {
throw new Error(RUNTIME_LEGACY_EXEC_DISABLED_ERROR);
}
return await runCommandWithTimeout(...args);
};
export function createPluginRuntime(): PluginRuntime {
return {
@@ -251,7 +266,7 @@ export function createPluginRuntime(): PluginRuntime {
},
system: {
enqueueSystemEvent,
runCommandWithTimeout: runtimeCommandExecutionDisabled,
runCommandWithTimeout: runtimeCommandExecutionGuarded,
formatNativeDependencyHint,
},
media: {

View File

@@ -184,7 +184,10 @@ export type PluginRuntime = {
};
system: {
enqueueSystemEvent: EnqueueSystemEvent;
/** @deprecated Runtime command execution is disabled at runtime for security hardening. */
/**
* @deprecated Disabled by default for security hardening.
* Set `plugins.runtime.allowLegacyExec: true` to opt in for legacy compatibility.
*/
runCommandWithTimeout: RunCommandWithTimeout;
formatNativeDependencyHint: FormatNativeDependencyHint;
};