diff --git a/src/agents/pi-tools.read.host-edit-access.test.ts b/src/agents/pi-tools.read.host-edit-access.test.ts new file mode 100644 index 00000000000..ca85c496148 --- /dev/null +++ b/src/agents/pi-tools.read.host-edit-access.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +type CapturedEditOperations = { + access: (absolutePath: string) => Promise; +}; + +const mocks = vi.hoisted(() => ({ + operations: undefined as CapturedEditOperations | undefined, +})); + +vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createEditTool: (_cwd: string, options?: { operations?: CapturedEditOperations }) => { + mocks.operations = options?.operations; + return { + name: "edit", + description: "test edit tool", + parameters: { type: "object", properties: {} }, + execute: async () => ({ + content: [{ type: "text" as const, text: "ok" }], + }), + }; + }, + }; +}); + +const { createHostWorkspaceEditTool } = await import("./pi-tools.read.js"); + +describe("createHostWorkspaceEditTool host access mapping", () => { + let tmpDir = ""; + + afterEach(async () => { + mocks.operations = undefined; + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = ""; + } + }); + + it.runIf(process.platform !== "win32")( + "maps outside-workspace safe-open failures to EACCES", + async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-access-test-")); + const workspaceDir = path.join(tmpDir, "workspace"); + const outsideDir = path.join(tmpDir, "outside"); + const linkDir = path.join(workspaceDir, "escape"); + const outsideFile = path.join(outsideDir, "secret.txt"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.symlink(outsideDir, linkDir); + + createHostWorkspaceEditTool(workspaceDir, { workspaceOnly: true }); + expect(mocks.operations).toBeDefined(); + + await expect( + mocks.operations!.access(path.join(workspaceDir, "escape", "secret.txt")), + ).rejects.toMatchObject({ code: "EACCES" }); + }, + ); +}); diff --git a/src/browser/paths.test.ts b/src/browser/paths.test.ts index 0fe27fe1e4e..90ef8afaabc 100644 --- a/src/browser/paths.test.ts +++ b/src/browser/paths.test.ts @@ -141,6 +141,28 @@ describe("resolveExistingPathsWithinRoot", () => { }, ); + it.runIf(process.platform !== "win32")( + "returns outside-root message for files reached via escaping symlinked directories", + async () => { + await withFixtureRoot(async ({ baseDir, uploadsDir }) => { + const outsideDir = path.join(baseDir, "outside"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, "secret.txt"), "secret", "utf8"); + await fs.symlink(outsideDir, path.join(uploadsDir, "alias")); + + const result = await resolveWithinUploads({ + uploadsDir, + requestedPaths: ["alias/secret.txt"], + }); + + expect(result).toEqual({ + ok: false, + error: "File is outside uploads directory", + }); + }); + }, + ); + it.runIf(process.platform !== "win32")( "accepts canonical absolute paths when upload root is a symlink alias", async () => { diff --git a/src/media/server.outside-workspace.test.ts b/src/media/server.outside-workspace.test.ts new file mode 100644 index 00000000000..be626bf3ed7 --- /dev/null +++ b/src/media/server.outside-workspace.test.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + openFileWithinRoot: vi.fn(), + cleanOldMedia: vi.fn().mockResolvedValue(undefined), +})); + +let mediaDir = ""; + +vi.mock("../infra/fs-safe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openFileWithinRoot: mocks.openFileWithinRoot, + }; +}); + +vi.mock("./store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getMediaDir: () => mediaDir, + cleanOldMedia: mocks.cleanOldMedia, + }; +}); + +const { SafeOpenError } = await import("../infra/fs-safe.js"); +const { startMediaServer } = await import("./server.js"); + +describe("media server outside-workspace mapping", () => { + let server: Awaited>; + let port = 0; + + beforeAll(async () => { + mediaDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-outside-workspace-")); + server = await startMediaServer(0, 1_000); + port = (server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + await fs.rm(mediaDir, { recursive: true, force: true }); + mediaDir = ""; + }); + + it("returns 400 with a specific outside-workspace message", async () => { + mocks.openFileWithinRoot.mockRejectedValueOnce( + new SafeOpenError("outside-workspace", "file is outside workspace root"), + ); + + const response = await fetch(`http://127.0.0.1:${port}/media/ok-id`); + expect(response.status).toBe(400); + expect(await response.text()).toBe("file is outside workspace root"); + }); +}); diff --git a/src/media/store.outside-workspace.test.ts b/src/media/store.outside-workspace.test.ts new file mode 100644 index 00000000000..6483a856cd9 --- /dev/null +++ b/src/media/store.outside-workspace.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; + +const mocks = vi.hoisted(() => ({ + readLocalFileSafely: vi.fn(), +})); + +vi.mock("../infra/fs-safe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readLocalFileSafely: mocks.readLocalFileSafely, + }; +}); + +const { saveMediaSource } = await import("./store.js"); +const { SafeOpenError } = await import("../infra/fs-safe.js"); + +describe("media store outside-workspace mapping", () => { + let tempHome: TempHomeEnv; + let home = ""; + + beforeAll(async () => { + tempHome = await createTempHomeEnv("openclaw-media-store-test-home-"); + home = tempHome.home; + }); + + afterAll(async () => { + await tempHome.restore(); + }); + + it("maps outside-workspace reads to a descriptive invalid-path error", async () => { + const sourcePath = path.join(home, "outside-media.txt"); + await fs.writeFile(sourcePath, "hello"); + mocks.readLocalFileSafely.mockRejectedValueOnce( + new SafeOpenError("outside-workspace", "file is outside workspace root"), + ); + + await expect(saveMediaSource(sourcePath)).rejects.toMatchObject({ + code: "invalid-path", + message: "Media path is outside workspace root", + }); + }); +});