From 2e421f32dfc589c02706265fd3c3137ffc06c4b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 16:01:22 +0100 Subject: [PATCH] fix(security): restore trusted plugin runtime exec default --- CHANGELOG.md | 2 +- SECURITY.md | 8 ++++++ src/config/schema.help.ts | 2 -- src/config/schema.labels.ts | 1 - src/config/types.plugins.ts | 9 ------- src/config/zod-schema.ts | 6 ----- src/plugins/runtime/index.test.ts | 44 +++++-------------------------- src/plugins/runtime/index.ts | 25 ++---------------- src/plugins/runtime/types.ts | 4 --- 9 files changed, 17 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fee2d249..f54916058f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. - Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. - Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. -- 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. +- Security/Plugins: for the next npm release, clarify plugin trust boundary and keep `runtime.system.runCommandWithTimeout` available by default for trusted in-process plugins. Thanks @markmusson for reporting. - 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/Gateway: fail startup when `hooks.token` matches `gateway.auth.token` so hooks and gateway token reuse is rejected at boot. (#20813) Thanks @coygeek. diff --git a/SECURITY.md b/SECURITY.md index c64b1ef99c..d02b9fb801 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,6 +49,14 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o - Using OpenClaw in ways that the docs recommend not to - Prompt injection attacks +## Plugin Trust Boundary + +Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code. + +- Plugins can execute with the same OS privileges as the OpenClaw process. +- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. +- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. + ## Operational Guidance For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see: diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 48d54abd1e..1c2356c3df 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -267,8 +267,6 @@ 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 277cecfcfd..b762588969 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -310,7 +310,6 @@ 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 f1e4211b1f..48e2d090ed 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -13,14 +13,6 @@ 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; @@ -44,7 +36,6 @@ 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 cc12924025..e8395fe983 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -631,12 +631,6 @@ 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 9254ebd553..008fa6fb49 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -1,48 +1,19 @@ 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", () => { - const blockedError = - "runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead."; - +describe("plugin runtime command execution", () => { 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(blockedError); - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); - }); - - it("allows runtime.system.runCommandWithTimeout when explicitly opted in", async () => { - loadConfigMock.mockReturnValue({ - plugins: { - runtime: { - allowLegacyExec: true, - }, - }, - }); + it("exposes runtime.system.runCommandWithTimeout by default", async () => { const commandResult = { stdout: "hello\n", stderr: "", @@ -60,15 +31,12 @@ describe("plugin runtime security hardening", () => { expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 }); }); - it("fails closed when config loading throws", async () => { - loadConfigMock.mockImplementation(() => { - throw new Error("config read failed"); - }); - + it("forwards runtime.system.runCommandWithTimeout errors", async () => { + runCommandWithTimeoutMock.mockRejectedValue(new Error("boom")); const runtime = createPluginRuntime(); await expect( runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }), - ).rejects.toThrow(blockedError); - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + ).rejects.toThrow("boom"); + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 }); }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 2ec668fb86..d5abe65600 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; +import type { PluginRuntime } from "./types.js"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; @@ -138,7 +139,6 @@ import { } from "../../web/auth-store.js"; import { loadWebMedia } from "../../web/media.js"; import { formatNativeDependencyHint } from "./native-deps.js"; -import type { PluginRuntime } from "./types.js"; let cachedVersion: string | null = null; @@ -236,27 +236,6 @@ function loadWhatsAppActions() { return whatsappActionsPromise; } -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 { version: resolveVersion(), @@ -266,7 +245,7 @@ export function createPluginRuntime(): PluginRuntime { }, system: { enqueueSystemEvent, - runCommandWithTimeout: runtimeCommandExecutionGuarded, + runCommandWithTimeout, formatNativeDependencyHint, }, media: { diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index c0a3f7fac6..71b85d6f12 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -184,10 +184,6 @@ export type PluginRuntime = { }; system: { enqueueSystemEvent: EnqueueSystemEvent; - /** - * @deprecated Disabled by default for security hardening. - * Set `plugins.runtime.allowLegacyExec: true` to opt in for legacy compatibility. - */ runCommandWithTimeout: RunCommandWithTimeout; formatNativeDependencyHint: FormatNativeDependencyHint; };