diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index bbab896ef9..326c353b5b 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -233,6 +233,426 @@ describe("lobster plugin tool", () => { } }); + it("rejects invalid JSON envelope from lobster", async () => { + const { dir } = await writeFakeLobsterScript( + `process.stdout.write(JSON.stringify({ hello: "world" }));\n`, + "openclaw-lobster-plugin-bad-envelope-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call3b", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/invalid JSON envelope/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("requires action", async () => { + const tool = createLobsterTool(fakeApi()); + await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/); + await expect(tool.execute("call-action-empty", { action: " " })).rejects.toThrow( + /action required/, + ); + }); + + it("rejects unknown action", async () => { + const tool = createLobsterTool(fakeApi()); + await expect(tool.execute("call-action-unknown", { action: "nope" })).rejects.toThrow( + /Unknown action/, + ); + }); + + it("validates run/resume parameters", async () => { + const tool = createLobsterTool(fakeApi()); + + await expect(tool.execute("call-run-missing-pipeline", { action: "run" })).rejects.toThrow( + /pipeline required/, + ); + await expect( + tool.execute("call-run-empty-pipeline", { action: "run", pipeline: " " }), + ).rejects.toThrow(/pipeline required/); + + await expect(tool.execute("call-resume-missing-token", { action: "resume" })).rejects.toThrow( + /token required/, + ); + await expect( + tool.execute("call-resume-empty-token", { action: "resume", token: " ", approve: true }), + ).rejects.toThrow(/token required/); + + await expect( + tool.execute("call-resume-missing-approve", { action: "resume", token: "t" }), + ).rejects.toThrow(/approve required/); + await expect( + tool.execute("call-resume-non-boolean-approve", { + action: "resume", + token: "t", + approve: "yes", + }), + ).rejects.toThrow(/approve required/); + }); + + it("rejects pluginConfig.lobsterPath when not absolute", async () => { + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: "./lobster" } })); + await expect( + tool.execute("call-plugin-config-relative", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/absolute path/); + }); + + it("rejects pluginConfig.lobsterPath when it does not exist", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-missing-")); + const missingPath = path.join(dir, "lobster"); + + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: missingPath } })); + await expect( + tool.execute("call-plugin-config-missing", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/must exist/); + }); + + it("rejects pluginConfig.lobsterPath when it points to a directory", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-dir-")); + const lobsterDir = path.join(dir, "lobster"); + await fs.mkdir(lobsterDir); + + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterDir } })); + await expect( + tool.execute("call-plugin-config-dir", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/point to a file/); + }); + + it("rejects pluginConfig.lobsterPath when it is not executable (posix)", async () => { + if (process.platform === "win32") { + return; + } + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-nonexec-")); + const binPath = path.join(dir, "lobster"); + await fs.writeFile(binPath, "#!/usr/bin/env node\nprocess.stdout.write('[]')\n", { + encoding: "utf8", + mode: 0o644, + }); + + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: binPath } })); + await expect( + tool.execute("call-plugin-config-nonexec", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/executable/); + }); + + it("trims pluginConfig.lobsterPath", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [], requiresApproval: null }, + }); + + // Ensure `lobster` is NOT discoverable via PATH, while still allowing our + // fake lobster (a Node script with `#!/usr/bin/env node`) to run. + const originalPath = process.env.PATH; + process.env.PATH = path.dirname(process.execPath); + + try { + const tool = createLobsterTool( + fakeApi({ pluginConfig: { lobsterPath: ` ${fake.binPath} ` } }), + ); + const res = await tool.execute("call-plugin-config-trim", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + } finally { + process.env.PATH = originalPath; + } + }); + + it("ignores non-string pluginConfig.lobsterPath", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [], requiresApproval: null }, + }); + + const originalPath = process.env.PATH; + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: 123 as any } })); + const res = await tool.execute("call-plugin-config-non-string", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + } finally { + process.env.PATH = originalPath; + } + }); + + it("validates deprecated lobsterPath even though it is ignored", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [], requiresApproval: null }, + }); + + // Ensure `lobster` is NOT discoverable via PATH, while still allowing our + // fake lobster to run via plugin config. + const originalPath = process.env.PATH; + process.env.PATH = path.dirname(process.execPath); + + try { + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } })); + await expect( + tool.execute("call-deprecated-invalid-with-plugin-config", { + action: "run", + pipeline: "noop", + lobsterPath: "/bin/bash", + timeoutMs: 1000, + }), + ).rejects.toThrow(/lobster executable/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("rejects lobsterPath injection attempts", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-lobsterpath-injection", { + action: "run", + pipeline: "noop", + lobsterPath: "/tmp/lobster --help", + }), + ).rejects.toThrow(/lobster executable/); + }); + + it("defaults cwd when empty or non-string", async () => { + const payload = { + ok: true, + status: "ok", + output: [{ cwd: "__REPLACED__" }], + requiresApproval: null, + }; + + const { dir } = await writeFakeLobsterScript( + `const payload = ${JSON.stringify(payload)};\n` + + `payload.output[0].cwd = process.cwd();\n` + + `process.stdout.write(JSON.stringify(payload));\n`, + "openclaw-lobster-plugin-cwd-default-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + const res1 = await tool.execute("call-cwd-empty", { + action: "run", + pipeline: "noop", + cwd: " ", + timeoutMs: 1000, + }); + expect((res1.details as any).output[0].cwd).toBe(process.cwd()); + + const res2 = await tool.execute("call-cwd-non-string", { + action: "run", + pipeline: "noop", + cwd: 123, + timeoutMs: 1000, + }); + expect((res2.details as any).output[0].cwd).toBe(process.cwd()); + } finally { + process.env.PATH = originalPath; + } + }); + + it("uses trimmed relative cwd within the gateway working directory", async () => { + const relDir = `.vitest-lobster-cwd-${Date.now()}`; + const absDir = path.join(process.cwd(), relDir); + await fs.mkdir(absDir); + + const payload = { + ok: true, + status: "ok", + output: [{ cwd: "__REPLACED__" }], + requiresApproval: null, + }; + + const { dir } = await writeFakeLobsterScript( + `const payload = ${JSON.stringify(payload)};\n` + + `payload.output[0].cwd = process.cwd();\n` + + `process.stdout.write(JSON.stringify(payload));\n`, + "openclaw-lobster-plugin-cwd-allowed-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call-cwd-trim", { + action: "run", + pipeline: "noop", + cwd: ` ${relDir} `, + timeoutMs: 1000, + }); + expect((res.details as any).output[0].cwd).toBe(absDir); + } finally { + process.env.PATH = originalPath; + await fs.rm(absDir, { recursive: true, force: true }); + } + }); + + it("rejects cwd that escapes via symlink", async () => { + if (process.platform === "win32") { + // Windows symlink creation can require elevated privileges in CI. + return; + } + + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-outside-")); + const linkName = `.vitest-lobster-symlink-${Date.now()}`; + const linkPath = path.join(process.cwd(), linkName); + + await fs.symlink(outside, linkPath, "dir"); + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-cwd-symlink-escape", { + action: "run", + pipeline: "noop", + cwd: linkName, + }), + ).rejects.toThrow(/must stay within/); + } finally { + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); + } + }); + + it("enforces maxStdoutBytes", async () => { + const { dir } = await writeFakeLobsterScript( + `process.stdout.write("x".repeat(20_000));\n`, + "openclaw-lobster-plugin-stdout-limit-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-stdout-limit", { + action: "run", + pipeline: "noop", + timeoutMs: 2000, + maxStdoutBytes: 1024, + }), + ).rejects.toThrow(/maxStdoutBytes/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("times out lobster subprocess", async () => { + const { dir } = await writeFakeLobsterScript( + `setTimeout(() => {}, 10_000);\n`, + "openclaw-lobster-plugin-timeout-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-timeout", { + action: "run", + pipeline: "noop", + timeoutMs: 250, + }), + ).rejects.toThrow(/timed out/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("removes NODE_OPTIONS containing --inspect from child env", async () => { + const payload = { + ok: true, + status: "ok", + output: [{ nodeOptions: "__REPLACED__" }], + requiresApproval: null, + }; + + const { dir } = await writeFakeLobsterScript( + `const payload = ${JSON.stringify(payload)};\n` + + `payload.output[0].nodeOptions = process.env.NODE_OPTIONS ?? null;\n` + + `process.stdout.write(JSON.stringify(payload));\n`, + "openclaw-lobster-plugin-node-options-", + ); + + const originalPath = process.env.PATH; + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + process.env.NODE_OPTIONS = "--inspect=0"; + + try { + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call-node-options", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect((res.details as any).output[0].nodeOptions).toBeNull(); + } finally { + process.env.PATH = originalPath; + process.env.NODE_OPTIONS = originalNodeOptions; + } + }); + + it("runs on Windows when lobster is only available as lobster.cmd on PATH (shell fallback)", async () => { + if (process.platform !== "win32") { + return; + } + + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [{ hello: "win" }], requiresApproval: null }, + }); + + const originalPath = process.env.PATH; + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call-win-shell-fallback", { + action: "run", + pipeline: "noop", + timeoutMs: 2000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + } finally { + process.env.PATH = originalPath; + } + }); + it("can be gated off in sandboxed contexts", async () => { const api = fakeApi(); const factoryTool = (ctx: OpenClawPluginToolContext) => { diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index dd510e2992..2632ac8cc7 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -82,6 +82,28 @@ function resolveCwd(cwdRaw: unknown): string { if (rel.startsWith("..") || path.isAbsolute(rel)) { throw new Error("cwd must stay within the gateway working directory"); } + + // SECURITY: prevent escapes via symlinks that point outside the base dir. + // If the path exists, compare its realpath with the base realpath. + if (fs.existsSync(resolved)) { + let baseReal: string; + let resolvedReal: string; + try { + baseReal = fs.realpathSync(base); + resolvedReal = fs.realpathSync(resolved); + } catch { + throw new Error("cwd must stay within the gateway working directory"); + } + + const relReal = path.relative( + normalizeForCwdSandbox(baseReal), + normalizeForCwdSandbox(resolvedReal), + ); + if (relReal.startsWith("..") || path.isAbsolute(relReal)) { + throw new Error("cwd must stay within the gateway working directory"); + } + } + return resolved; }