Security: disable plugin runtime command execution primitive (#20828)

Co-authored-by: mbelinky <mbelinky@users.noreply.github.com>
This commit is contained in:
Mariano
2026-02-19 10:17:29 +00:00
committed by GitHub
parent 771af40913
commit 45db2aa0cd
5 changed files with 179 additions and 12 deletions

View File

@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
@@ -36,6 +37,77 @@ type ResolveAuthResult = {
error?: string;
};
type CommandResult = {
code: number;
stdout: string;
stderr: string;
};
async function runFixedCommandWithTimeout(
argv: string[],
timeoutMs: number,
): Promise<CommandResult> {
return await new Promise((resolve) => {
const [command, ...args] = argv;
if (!command) {
resolve({ code: 1, stdout: "", stderr: "command is required" });
return;
}
const proc = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | null = null;
const finalize = (result: CommandResult) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
proc.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
timer = setTimeout(() => {
proc.kill("SIGKILL");
finalize({
code: 124,
stdout,
stderr: stderr || `command timed out after ${timeoutMs}ms`,
});
}, timeoutMs);
proc.on("error", (err) => {
finalize({
code: 1,
stdout,
stderr: err.message,
});
});
proc.on("close", (code) => {
finalize({
code: code ?? 1,
stdout,
stderr,
});
});
});
}
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const candidate = raw.trim();
if (!candidate) {
@@ -166,16 +238,11 @@ function pickTailnetIPv4(): string | null {
return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {
async function resolveTailnetHost(): Promise<string | null> {
const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
for (const candidate of candidates) {
try {
const result = await api.runtime.system.runCommandWithTimeout(
[candidate, "status", "--json"],
{
timeoutMs: 5000,
},
);
const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000);
if (result.code !== 0) {
continue;
}
@@ -283,7 +350,7 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHost(api);
const host = await resolveTailnetHost();
if (!host) {
return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." };
}

View File

@@ -1,9 +1,9 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
@@ -22,6 +22,85 @@ function resolvePluginRoot(): string {
return path.resolve(currentDir, "..", "..");
}
type CommandResult = {
code: number;
stdout: string;
stderr: string;
};
async function runFixedCommandWithTimeout(params: {
argv: string[];
cwd: string;
timeoutMs: number;
env?: NodeJS.ProcessEnv;
}): Promise<CommandResult> {
return await new Promise((resolve) => {
const [command, ...args] = params.argv;
if (!command) {
resolve({
code: 1,
stdout: "",
stderr: "command is required",
});
return;
}
const proc = spawn(command, args, {
cwd: params.cwd,
env: { ...process.env, ...params.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | null = null;
const finalize = (result: CommandResult) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
proc.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
timer = setTimeout(() => {
proc.kill("SIGKILL");
finalize({
code: 124,
stdout,
stderr: stderr || `command timed out after ${params.timeoutMs}ms`,
});
}, params.timeoutMs);
proc.on("error", (err) => {
finalize({
code: 1,
stdout,
stderr: err.message,
});
});
proc.on("close", (code) => {
finalize({
code: code ?? 1,
stdout,
stderr,
});
});
});
}
export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>;
@@ -42,7 +121,8 @@ export async function ensureMatrixSdkInstalled(params: {
? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
const result = await runFixedCommandWithTimeout({
argv: command,
cwd: root,
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { createPluginRuntime } from "./index.js";
describe("plugin runtime security hardening", () => {
it("blocks runtime.system.runCommandWithTimeout", 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.",
);
});
});

View File

@@ -105,7 +105,6 @@ 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";
@@ -236,6 +235,13 @@ 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.",
);
};
export function createPluginRuntime(): PluginRuntime {
return {
version: resolveVersion(),
@@ -245,7 +251,7 @@ export function createPluginRuntime(): PluginRuntime {
},
system: {
enqueueSystemEvent,
runCommandWithTimeout,
runCommandWithTimeout: runtimeCommandExecutionDisabled,
formatNativeDependencyHint,
},
media: {

View File

@@ -184,6 +184,7 @@ export type PluginRuntime = {
};
system: {
enqueueSystemEvent: EnqueueSystemEvent;
/** @deprecated Runtime command execution is disabled at runtime for security hardening. */
runCommandWithTimeout: RunCommandWithTimeout;
formatNativeDependencyHint: FormatNativeDependencyHint;
};