From ca8121d22bc8dce8a2f2d524c665e31adfd2ece6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=BB=E5=BD=B9?= Date: Thu, 16 Apr 2026 12:26:30 +0800 Subject: [PATCH] fix: add root guard to prevent CLI execution as root (#67478) Block openclaw CLI from running as root (uid 0) to prevent: - Separate state directory at /root/.openclaw/ - Conflicting systemd user services racing on port 18789 - Root-owned files in the service user state dir (EACCES) The guard runs early in src/entry.ts before any state/config operations. Root-level --help and --version bypass the guard so users can discover the OPENCLAW_ALLOW_ROOT=1 override. Subcommand help paths still enforce the guard since they enter runCli() and resolve state directories. Closes #67478 --- src/cli/root-guard.test.ts | 71 ++++++++++++++++++++++++++++++++++++++ src/cli/root-guard.ts | 34 ++++++++++++++++++ src/entry.ts | 10 +++++- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/cli/root-guard.test.ts create mode 100644 src/cli/root-guard.ts diff --git a/src/cli/root-guard.test.ts b/src/cli/root-guard.test.ts new file mode 100644 index 00000000000..c44ba57b542 --- /dev/null +++ b/src/cli/root-guard.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("assertNotRoot", () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + // Save and restore real getuid so we can replace it per test. + const realGetuid = process.getuid; + + beforeEach(() => { + exitSpy.mockClear(); + stderrSpy.mockClear(); + process.getuid = realGetuid; + }); + + afterAll(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + process.getuid = realGetuid; + }); + + // Use a fresh import each time to avoid module-level caching issues. + async function loadAssertNotRoot() { + const mod = await import("./root-guard.js"); + return mod.assertNotRoot; + } + + it("exits with code 1 when uid is 0 and no env override", async () => { + process.getuid = () => 0; + const assertNotRoot = await loadAssertNotRoot(); + assertNotRoot({}); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not exit when uid is 0 and OPENCLAW_ALLOW_ROOT=1", async () => { + process.getuid = () => 0; + const assertNotRoot = await loadAssertNotRoot(); + assertNotRoot({ OPENCLAW_ALLOW_ROOT: "1" }); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("does not exit when uid is non-zero", async () => { + process.getuid = () => 1000; + const assertNotRoot = await loadAssertNotRoot(); + assertNotRoot({}); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("does not exit when getuid is undefined (Windows)", async () => { + process.getuid = undefined as unknown as typeof process.getuid; + const assertNotRoot = await loadAssertNotRoot(); + assertNotRoot({}); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("error message mentions OPENCLAW_ALLOW_ROOT", async () => { + process.getuid = () => 0; + const assertNotRoot = await loadAssertNotRoot(); + assertNotRoot({}); + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("OPENCLAW_ALLOW_ROOT"); + }); + + it("error message mentions running as a non-root user", async () => { + process.getuid = () => 0; + const assertNotRoot = await loadAssertNotRoot(); + assertNotRoot({}); + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("non-root user"); + }); +}); diff --git a/src/cli/root-guard.ts b/src/cli/root-guard.ts new file mode 100644 index 00000000000..c5174b756e8 --- /dev/null +++ b/src/cli/root-guard.ts @@ -0,0 +1,34 @@ +import process from "node:process"; + +/** + * Block CLI execution when running as root (uid 0) unless explicitly opted in. + * + * Running as root causes: + * - Separate state dir (/root/.openclaw/ vs /home//.openclaw/) + * - Conflicting systemd user services (port 18789 race) + * - Root-owned files in the service user's state dir (EACCES) + */ +export function assertNotRoot(env: NodeJS.ProcessEnv = process.env): void { + if (typeof process.getuid !== "function") { + return; + } + if (process.getuid() !== 0) { + return; + } + if (env.OPENCLAW_ALLOW_ROOT === "1") { + return; + } + process.stderr.write( + "[openclaw] Refusing to run as root.\n" + + "\n" + + "Running the CLI as root causes:\n" + + " - A separate state directory under /root/.openclaw/ instead of the service user's\n" + + " - Conflicting systemd user services that race on port 18789\n" + + " - Root-owned files in the service user's state dir (EACCES errors)\n" + + "\n" + + "Run as a non-root user (e.g. su - ),\n" + + "or override this check:\n" + + " OPENCLAW_ALLOW_ROOT=1 openclaw ...\n", + ); + process.exit(1); +} diff --git a/src/entry.ts b/src/entry.ts index 87f60a30af6..e777feaef4d 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node import process from "node:process"; import { fileURLToPath } from "node:url"; -import { isRootHelpInvocation } from "./cli/argv.js"; +import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; +import { assertNotRoot } from "./cli/root-guard.js"; import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; @@ -91,6 +92,13 @@ if ( ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); normalizeEnv(); + + // Block root execution early, before any state/config operations. + // Allow --help and --version so users can still discover the override env var. + if (!isRootHelpInvocation(process.argv) && !isRootVersionInvocation(process.argv)) { + assertNotRoot(); + } + enableOpenClawCompileCache({ installRoot, });