diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 26fa37c451db..98ce06765cbd 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -114,6 +114,28 @@ describe("lobster plugin tool", () => { ).rejects.toThrow(/absolute path/); }); + it("rejects lobsterPath that is not the lobster executable", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2b", { + action: "run", + pipeline: "noop", + lobsterPath: "/bin/bash", + }), + ).rejects.toThrow(/lobster executable/); + }); + + it("rejects absolute cwd", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2c", { + action: "run", + pipeline: "noop", + cwd: "/tmp", + }), + ).rejects.toThrow(/cwd must be a relative path/); + }); + it("rejects invalid JSON from lobster", async () => { const { binPath } = await writeFakeLobsterScript( `process.stdout.write("nope");\n`, diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 5b8998fee48d..dac23197ddf8 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -1,5 +1,6 @@ import { Type } from "@sinclair/typebox"; import { spawn } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; @@ -23,12 +24,56 @@ type LobsterEnvelope = function resolveExecutablePath(lobsterPathRaw: string | undefined) { const lobsterPath = lobsterPathRaw?.trim() || "lobster"; - if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) { - throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); + + // SECURITY: + // Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides + // the path, it must still be the lobster binary (by name) and be absolute. + if (lobsterPath !== "lobster") { + if (!path.isAbsolute(lobsterPath)) { + throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); + } + const base = path.basename(lobsterPath).toLowerCase(); + const allowed = + process.platform === "win32" + ? ["lobster.exe", "lobster.cmd", "lobster.bat"] + : ["lobster"]; + if (!allowed.includes(base)) { + throw new Error("lobsterPath must point to the lobster executable"); + } + try { + const stat = fs.statSync(lobsterPath); + if (!stat.isFile()) { + throw new Error("lobsterPath must point to a file"); + } + if (process.platform !== "win32") { + fs.accessSync(lobsterPath, fs.constants.X_OK); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`lobsterPath is not executable: ${msg}`); + } } + return lobsterPath; } +function resolveCwd(cwdRaw: unknown): string { + if (typeof cwdRaw !== "string" || !cwdRaw.trim()) { + return process.cwd(); + } + const cwd = cwdRaw.trim(); + if (path.isAbsolute(cwd)) { + throw new Error("cwd must be a relative path"); + } + const base = process.cwd(); + const resolved = path.resolve(base, cwd); + const rel = path.relative(base, resolved); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error("cwd must stay within the gateway working directory"); + } + return resolved; +} + function isWindowsSpawnEINVAL(err: unknown) { if (!err || typeof err !== "object") { return false; @@ -182,8 +227,13 @@ export function createLobsterTool(api: OpenClawPluginApi) { argsJson: Type.Optional(Type.String()), token: Type.Optional(Type.String()), approve: Type.Optional(Type.Boolean()), - lobsterPath: Type.Optional(Type.String()), - cwd: Type.Optional(Type.String()), + lobsterPath: Type.Optional(Type.String({ description: "Absolute path to the lobster executable (optional)." })), + cwd: Type.Optional( + Type.String({ + description: + "Relative working directory (optional). Must stay within the gateway working directory.", + }), + ), timeoutMs: Type.Optional(Type.Number()), maxStdoutBytes: Type.Optional(Type.Number()), }), @@ -196,8 +246,7 @@ export function createLobsterTool(api: OpenClawPluginApi) { const execPath = resolveExecutablePath( typeof params.lobsterPath === "string" ? params.lobsterPath : undefined, ); - const cwd = - typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd(); + const cwd = resolveCwd(params.cwd); const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000; const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;