From 6ec4e5cf4abecccd7e59d639cc82f7804f26c31a Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Fri, 24 Apr 2026 12:06:52 +0800 Subject: [PATCH] fix: check effective UID (geteuid) in root guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertNotRoot only checked process.getuid(), so the guard was bypassed when the CLI was launched with a non-root real UID but an effective UID of 0 (e.g. via a setuid-root wrapper). In that context the process still has root write privileges and can cause the same state/config corruption the guard was added to prevent. Now checks both getuid() and geteuid() — either being 0 triggers the guard. Added three tests covering setuid-root scenarios. --- src/cli/root-guard.test.ts | 26 +++++++++++++++++++++++++- src/cli/root-guard.ts | 6 ++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/cli/root-guard.test.ts b/src/cli/root-guard.test.ts index 424a2037045..aa539ca5951 100644 --- a/src/cli/root-guard.test.ts +++ b/src/cli/root-guard.test.ts @@ -5,19 +5,22 @@ 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. + // Save and restore real getuid/geteuid so we can replace them per test. const realGetuid = process.getuid; + const realGeteuid = process.geteuid; beforeEach(() => { exitSpy.mockClear(); stderrSpy.mockClear(); process.getuid = realGetuid; + process.geteuid = realGeteuid; }); afterAll(() => { exitSpy.mockRestore(); stderrSpy.mockRestore(); process.getuid = realGetuid; + process.geteuid = realGeteuid; }); it("exits with code 1 when uid is 0 and no env override", () => { @@ -50,6 +53,27 @@ describe("assertNotRoot", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("exits when real uid is non-zero but effective uid is 0 (setuid-root)", () => { + process.getuid = () => 1000; + process.geteuid = () => 0; + assertNotRoot({}); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not exit when real uid is non-zero and effective uid is non-zero", () => { + process.getuid = () => 1000; + process.geteuid = () => 1000; + assertNotRoot({}); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("does not exit when euid is 0 but OPENCLAW_ALLOW_ROOT=1", () => { + process.getuid = () => 1000; + process.geteuid = () => 0; + assertNotRoot({ OPENCLAW_ALLOW_ROOT: "1" }); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it("does not exit when getuid is undefined (Windows)", () => { process.getuid = undefined as unknown as typeof process.getuid; assertNotRoot({}); diff --git a/src/cli/root-guard.ts b/src/cli/root-guard.ts index db133b5663b..81ec7447a40 100644 --- a/src/cli/root-guard.ts +++ b/src/cli/root-guard.ts @@ -1,7 +1,7 @@ import process from "node:process"; /** - * Block CLI execution when running as root (uid 0) unless explicitly opted in. + * Block CLI execution when running as root (uid 0 or euid 0) unless explicitly opted in. * * Running as root causes: * - Separate state dir (/root/.openclaw/ vs /home//.openclaw/) @@ -12,7 +12,9 @@ export function assertNotRoot(env: NodeJS.ProcessEnv = process.env): void { if (typeof process.getuid !== "function") { return; } - if (process.getuid() !== 0) { + const uid = process.getuid(); + const euid = typeof process.geteuid === "function" ? process.geteuid() : uid; + if (uid !== 0 && euid !== 0) { return; } if (