workspace-containment.test.ts

  1import { describe, test, expect, beforeAll, afterAll } from "bun:test";
  2import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
  3import { tmpdir } from "node:os";
  4import { join, resolve } from "node:path";
  5import { ensureWorkspacePath } from "../src/agent/tools/index.js";
  6import { expandPath, resolveToCwd, resolveReadPath } from "../src/agent/tools/path-utils.js";
  7import { writeWorkspaceFile } from "../src/workspace/content.js";
  8
  9let workspace: string;
 10
 11beforeAll(async () => {
 12	workspace = await mkdtemp(join(tmpdir(), "rumilo-test-"));
 13	await mkdir(join(workspace, "subdir"), { recursive: true });
 14	await writeFile(join(workspace, "hello.txt"), "hello");
 15	await writeFile(join(workspace, "subdir", "nested.txt"), "nested");
 16});
 17
 18afterAll(async () => {
 19	await rm(workspace, { recursive: true, force: true });
 20});
 21
 22// ─── ensureWorkspacePath ────────────────────────────────────────────
 23
 24describe("ensureWorkspacePath", () => {
 25	test("allows workspace root itself", () => {
 26		const result = ensureWorkspacePath(workspace, ".");
 27		expect(result).toBe(workspace);
 28	});
 29
 30	test("allows a relative child path", () => {
 31		const result = ensureWorkspacePath(workspace, "hello.txt");
 32		expect(result).toBe(join(workspace, "hello.txt"));
 33	});
 34
 35	test("allows nested relative path", () => {
 36		const result = ensureWorkspacePath(workspace, "subdir/nested.txt");
 37		expect(result).toBe(join(workspace, "subdir", "nested.txt"));
 38	});
 39
 40	test("rejects .. traversal escaping workspace", () => {
 41		expect(() => ensureWorkspacePath(workspace, "../../../etc/passwd")).toThrow("Path escapes workspace");
 42	});
 43
 44	test("rejects absolute path outside workspace", () => {
 45		expect(() => ensureWorkspacePath(workspace, "/etc/passwd")).toThrow("Path escapes workspace");
 46	});
 47
 48	test("allows absolute path inside workspace", () => {
 49		const absInside = join(workspace, "hello.txt");
 50		const result = ensureWorkspacePath(workspace, absInside);
 51		expect(result).toBe(absInside);
 52	});
 53});
 54
 55// ─── expandPath: tilde must NOT escape workspace ────────────────────
 56
 57describe("expandPath - tilde handling for workspace sandboxing", () => {
 58	test("tilde alone must not expand to homedir", () => {
 59		const result = expandPath("~");
 60		// After fix, ~ should remain literal (not expand to homedir)
 61		expect(result).toBe("~");
 62	});
 63
 64	test("tilde-prefixed path must not expand to homedir", () => {
 65		const result = expandPath("~/secret");
 66		expect(result).not.toContain("/home");
 67		expect(result).not.toContain("/Users");
 68		// Should stay as literal path
 69		expect(result).toBe("~/secret");
 70	});
 71});
 72
 73// ─── resolveToCwd: must stay within workspace ───────────────────────
 74
 75describe("resolveToCwd - workspace containment", () => {
 76	test("resolves relative path within workspace", () => {
 77		const result = resolveToCwd("hello.txt", workspace);
 78		expect(result).toBe(join(workspace, "hello.txt"));
 79	});
 80
 81	test("resolves '.' to workspace root", () => {
 82		const result = resolveToCwd(".", workspace);
 83		expect(result).toBe(workspace);
 84	});
 85});
 86
 87// ─── Tool-level containment (read tool) ─────────────────────────────
 88
 89describe("read tool - workspace containment", () => {
 90	let readTool: any;
 91
 92	beforeAll(async () => {
 93		const { createReadTool } = await import("../src/agent/tools/read.js");
 94		readTool = createReadTool(workspace);
 95	});
 96
 97	test("reads file inside workspace", async () => {
 98		const result = await readTool.execute("id", { path: "hello.txt" });
 99		expect(result.content[0].text).toBe("hello");
100	});
101
102	test("rejects traversal via ..", async () => {
103		await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow(
104			/escapes workspace/i,
105		);
106	});
107
108	test("rejects absolute path outside workspace", async () => {
109		await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow(
110			/escapes workspace/i,
111		);
112	});
113
114	test("tilde path stays within workspace (no homedir expansion)", async () => {
115		// With tilde expansion removed, ~/foo resolves to <workspace>/~/foo
116		// which is safely inside the workspace. It will fail with ENOENT,
117		// NOT succeed in reading the user's homedir file.
118		await expect(readTool.execute("id", { path: "~/.bashrc" })).rejects.toThrow(/ENOENT/);
119	});
120});
121
122// ─── Tool-level containment (ls tool) ───────────────────────────────
123
124describe("ls tool - workspace containment", () => {
125	let lsTool: any;
126
127	beforeAll(async () => {
128		const { createLsTool } = await import("../src/agent/tools/ls.js");
129		lsTool = createLsTool(workspace);
130	});
131
132	test("lists workspace root", async () => {
133		const result = await lsTool.execute("id", {});
134		expect(result.content[0].text).toContain("hello.txt");
135	});
136
137	test("rejects traversal via ..", async () => {
138		await expect(lsTool.execute("id", { path: "../../" })).rejects.toThrow(
139			/escapes workspace/i,
140		);
141	});
142
143	test("rejects absolute path outside workspace", async () => {
144		await expect(lsTool.execute("id", { path: "/tmp" })).rejects.toThrow(
145			/escapes workspace/i,
146		);
147	});
148});
149
150// ─── Tool-level containment (grep tool) ─────────────────────────────
151
152describe("grep tool - workspace containment", () => {
153	let grepTool: any;
154
155	beforeAll(async () => {
156		const { createGrepTool } = await import("../src/agent/tools/grep.js");
157		grepTool = createGrepTool(workspace);
158	});
159
160	test("searches within workspace", async () => {
161		const result = await grepTool.execute("id", { pattern: "hello", literal: true });
162		expect(result.content[0].text).toContain("hello");
163	});
164
165	test("rejects traversal via ..", async () => {
166		await expect(
167			grepTool.execute("id", { pattern: "root", path: "../../etc" }),
168		).rejects.toThrow(/escapes workspace/i);
169	});
170
171	test("rejects absolute path outside workspace", async () => {
172		await expect(
173			grepTool.execute("id", { pattern: "root", path: "/etc" }),
174		).rejects.toThrow(/escapes workspace/i);
175	});
176});
177
178// ─── Tool-level containment (find tool) ─────────────────────────────
179
180describe("find tool - workspace containment", () => {
181	let findTool: any;
182
183	beforeAll(async () => {
184		const { createFindTool } = await import("../src/agent/tools/find.js");
185		findTool = createFindTool(workspace);
186	});
187
188	test("finds files in workspace", async () => {
189		const result = await findTool.execute("id", { pattern: "*.txt" });
190		expect(result.content[0].text).toContain("hello.txt");
191	});
192
193	test("rejects traversal via ..", async () => {
194		await expect(
195			findTool.execute("id", { pattern: "*", path: "../../" }),
196		).rejects.toThrow(/escapes workspace/i);
197	});
198
199	test("rejects absolute path outside workspace", async () => {
200		await expect(
201			findTool.execute("id", { pattern: "*", path: "/tmp" }),
202		).rejects.toThrow(/escapes workspace/i);
203	});
204});
205
206// ─── writeWorkspaceFile containment (Issue #4) ──────────────────────
207
208describe("writeWorkspaceFile - workspace containment", () => {
209	test("writes file inside workspace", async () => {
210		const result = await writeWorkspaceFile(workspace, "output.txt", "data");
211		expect(result.filePath).toBe(join(workspace, "output.txt"));
212	});
213
214	test("writes nested file inside workspace", async () => {
215		const result = await writeWorkspaceFile(workspace, "a/b/c.txt", "deep");
216		expect(result.filePath).toBe(join(workspace, "a", "b", "c.txt"));
217	});
218
219	test("rejects traversal via ..", async () => {
220		await expect(
221			writeWorkspaceFile(workspace, "../../../tmp/evil.txt", "pwned"),
222		).rejects.toThrow(/escapes workspace/i);
223	});
224
225	test("absolute path via join stays inside workspace", async () => {
226		// path.join(workspace, "/tmp/evil.txt") => "<workspace>/tmp/evil.txt"
227		// This is actually inside the workspace β€” join concatenates, doesn't replace.
228		const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe");
229		expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt"));
230	});
231
232	test("tilde path via join stays inside workspace", async () => {
233		// With no tilde expansion, ~/evil.txt joins as <workspace>/~/evil.txt
234		const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe");
235		expect(result.filePath).toBe(join(workspace, "~", "evil.txt"));
236	});
237});