diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1dbc9756..e4df9c60a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e618779d00..2a59c153b0 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -267,6 +267,8 @@ export const FIELD_HELP: Record = { "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.', diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 95e75b8fb5..5b27235ef4 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -306,6 +306,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index dbe51f38e3..68268683e1 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -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; installs?: Record; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b47418302c..aa28f397a4 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 2f72e2b934..9254ebd553 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -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(); + 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(); }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 58f3c52df6..2ec668fb86 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -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: { diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 65ef2f856e..c0a3f7fac6 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -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; };