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, type ToolContext } 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("read_file", readFileTool); tools.register("write_file", writeFileTool); tools.register("list_files", 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 executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello"); }); it("reads nested files", async () => { expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world"); }); it("rejects .. escapes as error JSON, not a throw", async () => { const out = await executeTool(tools, "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 executeTool(tools, "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 executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx()); expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new"); }); it("creates parent dirs on write", async () => { await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx()); expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x"); }); it("lists the workspace root", async () => { const out = await executeTool(tools, "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 executeTool(tools, "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 executeTool(tools, "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 executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); }); async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise { const def = tools.build(ctx)[name]; if (def?.execute === undefined) { throw new Error(`missing executable tool: ${name}`); } return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise; }