import { describe, it, expect, beforeEach } from "vitest"; import { mkdtemp, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ToolRegistry } from "../../src/agent/tools.js"; import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js"; describe("workspace path confinement", () => { let ws: string; let tools: ToolRegistry; beforeEach(async () => { ws = await mkdtemp(join(tmpdir(), "hub-unit-")); tools = new ToolRegistry(); tools.register(readFileTool()); tools.register(writeFileTool()); tools.register(listFilesTool()); await mkdir(join(ws, "sub")); await writeFile(join(ws, "a.txt"), "hello"); await writeFile(join(ws, "sub", "b.txt"), "world"); }); const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws }); it("reads a file inside the workspace", async () => { expect(await tools.execute("read_file", { path: "a.txt" }, ctx())).toBe("hello"); }); it("reads nested files", async () => { expect(await tools.execute("read_file", { path: "sub/b.txt" }, ctx())).toBe("world"); }); it("rejects .. escapes as error JSON, not a throw", async () => { const out = await tools.execute("read_file", { path: "../../etc/passwd" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); it("rejects absolute paths outside the workspace", async () => { const out = await tools.execute("read_file", { path: "/etc/passwd" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); it("writes a file and reads it back", async () => { await tools.execute("write_file", { path: "sub/c.txt", content: "new" }, ctx()); expect(await tools.execute("read_file", { path: "sub/c.txt" }, ctx())).toBe("new"); }); it("creates parent dirs on write", async () => { await tools.execute("write_file", { path: "deep/nested/d.txt", content: "x" }, ctx()); expect(await tools.execute("read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x"); }); it("lists the workspace root", async () => { const out = await tools.execute("list_files", {}, ctx()); const entries = JSON.parse(out) as Array<{ name: string; kind: string }>; expect(entries).toContainEqual({ name: "a.txt", kind: "file" }); expect(entries).toContainEqual({ name: "sub", kind: "dir" }); }); it("lists a subdirectory", async () => { const out = await tools.execute("list_files", { path: "sub" }, ctx()); const entries = JSON.parse(out) as Array<{ name: string; kind: string }>; expect(entries).toContainEqual({ name: "b.txt", kind: "file" }); }); it("PathEscape is an Error subclass", () => { expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error); expect(new PathEscape("../x", "/ws").name).toBe("PathEscape"); }); it("read of nonexistent file returns error JSON", async () => { const out = await tools.execute("read_file", { path: "nope.txt" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toBeTruthy(); }); it("write with .. escape returns error JSON", async () => { const out = await tools.execute("write_file", { path: "../escape.txt", content: "x" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); });