|
| 1 | +import { constants as fsConstants } from "node:fs"; |
1 | 2 | import fs from "node:fs/promises"; |
2 | 3 | import path from "node:path"; |
3 | | -import { describe, expect, it } from "vitest"; |
| 4 | +import { describe, expect, it, vi } from "vitest"; |
4 | 5 | import { withTempDir } from "../test-utils/temp-dir.js"; |
5 | 6 | import { createExecTool } from "./bash-tools.exec.js"; |
6 | 7 |
|
@@ -74,6 +75,54 @@ describeNonWin("exec script preflight", () => { |
74 | 75 | }); |
75 | 76 | }); |
76 | 77 |
|
| 78 | + it("validates in-workdir scripts whose names start with '..'", async () => { |
| 79 | + await withTempDir("openclaw-exec-preflight-", async (tmp) => { |
| 80 | + const jsPath = path.join(tmp, "..bad.js"); |
| 81 | + await fs.writeFile(jsPath, "const value = $DM_JSON;", "utf-8"); |
| 82 | + |
| 83 | + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); |
| 84 | + await expect( |
| 85 | + tool.execute("call-dotdot-prefix-script", { |
| 86 | + command: "node ..bad.js", |
| 87 | + workdir: tmp, |
| 88 | + }), |
| 89 | + ).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + it("validates in-workdir symlinked script entrypoints", async () => { |
| 94 | + await withTempDir("openclaw-exec-preflight-", async (tmp) => { |
| 95 | + const targetPath = path.join(tmp, "bad-target.js"); |
| 96 | + const linkPath = path.join(tmp, "link.js"); |
| 97 | + await fs.writeFile(targetPath, "const value = $DM_JSON;", "utf-8"); |
| 98 | + await fs.symlink(targetPath, linkPath); |
| 99 | + |
| 100 | + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); |
| 101 | + await expect( |
| 102 | + tool.execute("call-symlink-entrypoint", { |
| 103 | + command: "node link.js", |
| 104 | + workdir: tmp, |
| 105 | + }), |
| 106 | + ).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/); |
| 107 | + }); |
| 108 | + }); |
| 109 | + |
| 110 | + it("validates scripts under literal tilde directories in workdir", async () => { |
| 111 | + await withTempDir("openclaw-exec-preflight-", async (tmp) => { |
| 112 | + const literalTildeDir = path.join(tmp, "~"); |
| 113 | + await fs.mkdir(literalTildeDir, { recursive: true }); |
| 114 | + await fs.writeFile(path.join(literalTildeDir, "bad.js"), "const value = $DM_JSON;", "utf-8"); |
| 115 | + |
| 116 | + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); |
| 117 | + await expect( |
| 118 | + tool.execute("call-literal-tilde-path", { |
| 119 | + command: 'node "~/bad.js"', |
| 120 | + workdir: tmp, |
| 121 | + }), |
| 122 | + ).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/); |
| 123 | + }); |
| 124 | + }); |
| 125 | + |
77 | 126 | it("validates python scripts when interpreter is prefixed with env", async () => { |
78 | 127 | await withTempDir("openclaw-exec-preflight-", async (tmp) => { |
79 | 128 | const pyPath = path.join(tmp, "bad.py"); |
@@ -268,6 +317,115 @@ describeNonWin("exec script preflight", () => { |
268 | 317 | }); |
269 | 318 | }); |
270 | 319 |
|
| 320 | + it("does not trust a swapped script pathname between validation and read", async () => { |
| 321 | + await withTempDir("openclaw-exec-preflight-race-", async (parent) => { |
| 322 | + const workdir = path.join(parent, "workdir"); |
| 323 | + const scriptPath = path.join(workdir, "script.js"); |
| 324 | + const outsidePath = path.join(parent, "outside.js"); |
| 325 | + await fs.mkdir(workdir, { recursive: true }); |
| 326 | + await fs.writeFile(scriptPath, 'console.log("inside")', "utf-8"); |
| 327 | + await fs.writeFile(outsidePath, 'console.log("$DM_JSON outside")', "utf-8"); |
| 328 | + |
| 329 | + const originalStat = fs.stat.bind(fs); |
| 330 | + let swapped = false; |
| 331 | + const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { |
| 332 | + const target = args[0]; |
| 333 | + if (!swapped && typeof target === "string" && path.resolve(target) === scriptPath) { |
| 334 | + const original = await originalStat(target); |
| 335 | + await fs.rm(scriptPath, { force: true }); |
| 336 | + await fs.symlink(outsidePath, scriptPath); |
| 337 | + swapped = true; |
| 338 | + return original; |
| 339 | + } |
| 340 | + return await originalStat(...args); |
| 341 | + }); |
| 342 | + |
| 343 | + try { |
| 344 | + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); |
| 345 | + const result = await tool.execute("call-swapped-pathname", { |
| 346 | + command: "node script.js", |
| 347 | + workdir, |
| 348 | + }); |
| 349 | + const text = result.content.find((block) => block.type === "text")?.text ?? ""; |
| 350 | + expect(swapped).toBe(true); |
| 351 | + expect(text).not.toMatch(/exec preflight:/); |
| 352 | + } finally { |
| 353 | + statSpy.mockRestore(); |
| 354 | + } |
| 355 | + }); |
| 356 | + }); |
| 357 | + |
| 358 | + it("handles pre-open symlink swaps without surfacing preflight errors", async () => { |
| 359 | + await withTempDir("openclaw-exec-preflight-open-race-", async (parent) => { |
| 360 | + const workdir = path.join(parent, "workdir"); |
| 361 | + const scriptPath = path.join(workdir, "script.js"); |
| 362 | + const outsidePath = path.join(parent, "outside.js"); |
| 363 | + await fs.mkdir(workdir, { recursive: true }); |
| 364 | + await fs.writeFile(scriptPath, 'console.log("inside")', "utf-8"); |
| 365 | + await fs.writeFile(outsidePath, 'console.log("$DM_JSON outside")', "utf-8"); |
| 366 | + |
| 367 | + const originalOpen = fs.open.bind(fs); |
| 368 | + let swapped = false; |
| 369 | + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => { |
| 370 | + const target = args[0]; |
| 371 | + if (!swapped && typeof target === "string" && path.resolve(target) === scriptPath) { |
| 372 | + await fs.rm(scriptPath, { force: true }); |
| 373 | + await fs.symlink(outsidePath, scriptPath); |
| 374 | + swapped = true; |
| 375 | + } |
| 376 | + return await originalOpen(...args); |
| 377 | + }); |
| 378 | + |
| 379 | + try { |
| 380 | + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); |
| 381 | + const result = await tool.execute("call-pre-open-swapped-pathname", { |
| 382 | + command: "node script.js", |
| 383 | + workdir, |
| 384 | + }); |
| 385 | + const text = result.content.find((block) => block.type === "text")?.text ?? ""; |
| 386 | + expect(swapped).toBe(true); |
| 387 | + expect(text).not.toMatch(/exec preflight:/); |
| 388 | + } finally { |
| 389 | + openSpy.mockRestore(); |
| 390 | + } |
| 391 | + }); |
| 392 | + }); |
| 393 | + |
| 394 | + it("opens preflight script reads with O_NONBLOCK to avoid FIFO stalls", async () => { |
| 395 | + await withTempDir("openclaw-exec-preflight-nonblock-", async (tmp) => { |
| 396 | + const scriptPath = path.join(tmp, "script.js"); |
| 397 | + await fs.writeFile(scriptPath, 'console.log("ok")', "utf-8"); |
| 398 | + |
| 399 | + const originalOpen = fs.open.bind(fs); |
| 400 | + const scriptOpenFlags: number[] = []; |
| 401 | + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => { |
| 402 | + const [target, flags] = args; |
| 403 | + if ( |
| 404 | + typeof target === "string" && |
| 405 | + path.resolve(target) === scriptPath && |
| 406 | + typeof flags === "number" |
| 407 | + ) { |
| 408 | + scriptOpenFlags.push(flags); |
| 409 | + } |
| 410 | + return await originalOpen(...args); |
| 411 | + }); |
| 412 | + |
| 413 | + try { |
| 414 | + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); |
| 415 | + const result = await tool.execute("call-nonblocking-preflight-open", { |
| 416 | + command: "node script.js", |
| 417 | + workdir: tmp, |
| 418 | + }); |
| 419 | + const text = result.content.find((block) => block.type === "text")?.text ?? ""; |
| 420 | + expect(scriptOpenFlags.length).toBeGreaterThan(0); |
| 421 | + expect(scriptOpenFlags.some((flags) => (flags & fsConstants.O_NONBLOCK) !== 0)).toBe(true); |
| 422 | + expect(text).not.toMatch(/exec preflight:/); |
| 423 | + } finally { |
| 424 | + openSpy.mockRestore(); |
| 425 | + } |
| 426 | + }); |
| 427 | + }); |
| 428 | + |
271 | 429 | it("fails closed for piped interpreter commands that bypass direct script parsing", async () => { |
272 | 430 | await withTempDir("openclaw-exec-preflight-", async (tmp) => { |
273 | 431 | const pyPath = path.join(tmp, "bad.py"); |
|
0 commit comments