workspace-containment.test.ts

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: GPL-3.0-or-later
  4
  5import { describe, test, expect, beforeAll, afterAll } from "bun:test";
  6import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
  7import { symlinkSync } from "node:fs";
  8import { tmpdir } from "node:os";
  9import { join, resolve } from "node:path";
 10import { ensureWorkspacePath } from "../src/agent/tools/index.js";
 11import { expandPath, resolveToCwd, resolveReadPath } from "../src/agent/tools/path-utils.js";
 12import { writeWorkspaceFile } from "../src/workspace/content.js";
 13
 14let workspace: string;
 15
 16beforeAll(async () => {
 17	workspace = await mkdtemp(join(tmpdir(), "rumilo-test-"));
 18	await mkdir(join(workspace, "subdir"), { recursive: true });
 19	await writeFile(join(workspace, "hello.txt"), "hello");
 20	await writeFile(join(workspace, "subdir", "nested.txt"), "nested");
 21});
 22
 23afterAll(async () => {
 24	await rm(workspace, { recursive: true, force: true });
 25});
 26
 27// ─── ensureWorkspacePath ────────────────────────────────────────────
 28
 29describe("ensureWorkspacePath", () => {
 30	test("allows workspace root itself", () => {
 31		const result = ensureWorkspacePath(workspace, ".");
 32		expect(result).toBe(workspace);
 33	});
 34
 35	test("allows a relative child path", () => {
 36		const result = ensureWorkspacePath(workspace, "hello.txt");
 37		expect(result).toBe(join(workspace, "hello.txt"));
 38	});
 39
 40	test("allows nested relative path", () => {
 41		const result = ensureWorkspacePath(workspace, "subdir/nested.txt");
 42		expect(result).toBe(join(workspace, "subdir", "nested.txt"));
 43	});
 44
 45	test("rejects .. traversal escaping workspace", () => {
 46		expect(() => ensureWorkspacePath(workspace, "../../../etc/passwd")).toThrow("Path escapes workspace");
 47	});
 48
 49	test("rejects absolute path outside workspace", () => {
 50		expect(() => ensureWorkspacePath(workspace, "/etc/passwd")).toThrow("Path escapes workspace");
 51	});
 52
 53	test("allows absolute path inside workspace", () => {
 54		const absInside = join(workspace, "hello.txt");
 55		const result = ensureWorkspacePath(workspace, absInside);
 56		expect(result).toBe(absInside);
 57	});
 58});
 59
 60// ─── expandPath: tilde must NOT escape workspace ────────────────────
 61
 62describe("expandPath - tilde handling for workspace sandboxing", () => {
 63	test("tilde alone must not expand to homedir", () => {
 64		const result = expandPath("~");
 65		// After fix, ~ should remain literal (not expand to homedir)
 66		expect(result).toBe("~");
 67	});
 68
 69	test("tilde-prefixed path must not expand to homedir", () => {
 70		const result = expandPath("~/secret");
 71		expect(result).not.toContain("/home");
 72		expect(result).not.toContain("/Users");
 73		// Should stay as literal path
 74		expect(result).toBe("~/secret");
 75	});
 76});
 77
 78// ─── resolveToCwd: must stay within workspace ───────────────────────
 79
 80describe("resolveToCwd - workspace containment", () => {
 81	test("resolves relative path within workspace", () => {
 82		const result = resolveToCwd("hello.txt", workspace);
 83		expect(result).toBe(join(workspace, "hello.txt"));
 84	});
 85
 86	test("resolves '.' to workspace root", () => {
 87		const result = resolveToCwd(".", workspace);
 88		expect(result).toBe(workspace);
 89	});
 90});
 91
 92// ─── Tool-level containment (read tool) ─────────────────────────────
 93
 94describe("read tool - workspace containment", () => {
 95	let readTool: any;
 96
 97	beforeAll(async () => {
 98		const { createReadTool } = await import("../src/agent/tools/read.js");
 99		readTool = createReadTool(workspace);
100	});
101
102	test("reads file inside workspace", async () => {
103		const result = await readTool.execute("id", { path: "hello.txt" });
104		expect(result.content[0].text).toBe("hello");
105	});
106
107	test("rejects traversal via ..", async () => {
108		await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow(
109			/escapes workspace/i,
110		);
111	});
112
113	test("rejects absolute path outside workspace", async () => {
114		await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow(
115			/escapes workspace/i,
116		);
117	});
118
119	test("tilde path stays within workspace (no homedir expansion)", async () => {
120		// With tilde expansion removed, ~/foo resolves to <workspace>/~/foo
121		// which is safely inside the workspace. It will fail with ENOENT,
122		// NOT succeed in reading the user's homedir file.
123		await expect(readTool.execute("id", { path: "~/.bashrc" })).rejects.toThrow(/ENOENT/);
124	});
125});
126
127// ─── Tool-level containment (ls tool) ───────────────────────────────
128
129describe("ls tool - workspace containment", () => {
130	let lsTool: any;
131
132	beforeAll(async () => {
133		const { createLsTool } = await import("../src/agent/tools/ls.js");
134		lsTool = createLsTool(workspace);
135	});
136
137	test("lists workspace root", async () => {
138		const result = await lsTool.execute("id", {});
139		expect(result.content[0].text).toContain("hello.txt");
140	});
141
142	test("rejects traversal via ..", async () => {
143		await expect(lsTool.execute("id", { path: "../../" })).rejects.toThrow(
144			/escapes workspace/i,
145		);
146	});
147
148	test("rejects absolute path outside workspace", async () => {
149		await expect(lsTool.execute("id", { path: "/tmp" })).rejects.toThrow(
150			/escapes workspace/i,
151		);
152	});
153});
154
155// ─── Tool-level containment (grep tool) ─────────────────────────────
156
157describe("grep tool - workspace containment", () => {
158	let grepTool: any;
159
160	beforeAll(async () => {
161		const { createGrepTool } = await import("../src/agent/tools/grep.js");
162		grepTool = createGrepTool(workspace);
163	});
164
165	test("searches within workspace", async () => {
166		const result = await grepTool.execute("id", { pattern: "hello", literal: true });
167		expect(result.content[0].text).toContain("hello");
168	});
169
170	test("rejects traversal via ..", async () => {
171		await expect(
172			grepTool.execute("id", { pattern: "root", path: "../../etc" }),
173		).rejects.toThrow(/escapes workspace/i);
174	});
175
176	test("rejects absolute path outside workspace", async () => {
177		await expect(
178			grepTool.execute("id", { pattern: "root", path: "/etc" }),
179		).rejects.toThrow(/escapes workspace/i);
180	});
181});
182
183// ─── Tool-level containment (find tool) ─────────────────────────────
184
185describe("find tool - workspace containment", () => {
186	let findTool: any;
187
188	beforeAll(async () => {
189		const { createFindTool } = await import("../src/agent/tools/find.js");
190		findTool = createFindTool(workspace);
191	});
192
193	test("finds files in workspace", async () => {
194		const result = await findTool.execute("id", { pattern: "*.txt" });
195		expect(result.content[0].text).toContain("hello.txt");
196	});
197
198	test("rejects traversal via ..", async () => {
199		await expect(
200			findTool.execute("id", { pattern: "*", path: "../../" }),
201		).rejects.toThrow(/escapes workspace/i);
202	});
203
204	test("rejects absolute path outside workspace", async () => {
205		await expect(
206			findTool.execute("id", { pattern: "*", path: "/tmp" }),
207		).rejects.toThrow(/escapes workspace/i);
208	});
209});
210
211// ─── writeWorkspaceFile containment (Issue #4) ──────────────────────
212
213describe("writeWorkspaceFile - workspace containment", () => {
214	test("writes file inside workspace", async () => {
215		const result = await writeWorkspaceFile(workspace, "output.txt", "data");
216		expect(result.filePath).toBe(join(workspace, "output.txt"));
217	});
218
219	test("writes nested file inside workspace", async () => {
220		const result = await writeWorkspaceFile(workspace, "a/b/c.txt", "deep");
221		expect(result.filePath).toBe(join(workspace, "a", "b", "c.txt"));
222	});
223
224	test("rejects traversal via ..", async () => {
225		await expect(
226			writeWorkspaceFile(workspace, "../../../tmp/evil.txt", "pwned"),
227		).rejects.toThrow(/escapes workspace/i);
228	});
229
230	test("absolute path via join stays inside workspace", async () => {
231		// path.join(workspace, "/tmp/evil.txt") => "<workspace>/tmp/evil.txt"
232		// This is actually inside the workspace β€” join concatenates, doesn't replace.
233		const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe");
234		expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt"));
235	});
236
237	test("tilde path via join stays inside workspace", async () => {
238		// With no tilde expansion, ~/evil.txt joins as <workspace>/~/evil.txt
239		const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe");
240		expect(result.filePath).toBe(join(workspace, "~", "evil.txt"));
241	});
242});
243
244// ─── Symlink containment ────────────────────────────────────────────
245
246describe("symlink containment", () => {
247	let symlinkWorkspace: string;
248	let outsideDir: string;
249
250	beforeAll(async () => {
251		symlinkWorkspace = await mkdtemp(join(tmpdir(), "rumilo-symlink-test-"));
252		outsideDir = await mkdtemp(join(tmpdir(), "rumilo-outside-"));
253
254		// Create a regular file inside workspace
255		await writeFile(join(symlinkWorkspace, "legit.txt"), "safe content");
256
257		// Create a subdirectory inside workspace
258		await mkdir(join(symlinkWorkspace, "subdir"), { recursive: true });
259		await writeFile(join(symlinkWorkspace, "subdir", "inner.txt"), "inner content");
260
261		// Create a file outside workspace
262		await writeFile(join(outsideDir, "secret.txt"), "secret content");
263
264		// Symlink inside workspace pointing outside
265		symlinkSync(outsideDir, join(symlinkWorkspace, "escape-link"));
266
267		// Symlink inside workspace pointing to file outside
268		symlinkSync(join(outsideDir, "secret.txt"), join(symlinkWorkspace, "secret-link.txt"));
269
270		// Symlink inside workspace pointing to a file inside workspace (benign)
271		symlinkSync(join(symlinkWorkspace, "legit.txt"), join(symlinkWorkspace, "good-link.txt"));
272
273		// Nested symlink escape: subdir/nested-escape -> outsideDir
274		symlinkSync(outsideDir, join(symlinkWorkspace, "subdir", "nested-escape"));
275	});
276
277	afterAll(async () => {
278		await rm(symlinkWorkspace, { recursive: true, force: true });
279		await rm(outsideDir, { recursive: true, force: true });
280	});
281
282	test("rejects symlink directory pointing outside workspace", () => {
283		expect(() =>
284			ensureWorkspacePath(symlinkWorkspace, "escape-link/secret.txt"),
285		).toThrow(/escapes workspace via symlink/);
286	});
287
288	test("rejects symlink file pointing outside workspace", () => {
289		expect(() =>
290			ensureWorkspacePath(symlinkWorkspace, "secret-link.txt"),
291		).toThrow(/escapes workspace via symlink/);
292	});
293
294	test("allows symlink pointing within workspace", () => {
295		const result = ensureWorkspacePath(symlinkWorkspace, "good-link.txt");
296		expect(result).toBe(join(symlinkWorkspace, "good-link.txt"));
297	});
298
299	test("rejects nested symlink escape (subdir/nested-escape)", () => {
300		expect(() =>
301			ensureWorkspacePath(symlinkWorkspace, "subdir/nested-escape/secret.txt"),
302		).toThrow(/escapes workspace via symlink/);
303	});
304
305	test("rejects symlink escape via directory symlink alone", () => {
306		expect(() =>
307			ensureWorkspacePath(symlinkWorkspace, "escape-link"),
308		).toThrow(/escapes workspace via symlink/);
309	});
310
311	test("handles non-existent file in real directory (write target)", () => {
312		// File doesn't exist but parent is a real dir inside workspace β€” should pass
313		const result = ensureWorkspacePath(symlinkWorkspace, "subdir/new-file.txt");
314		expect(result).toBe(join(symlinkWorkspace, "subdir", "new-file.txt"));
315	});
316
317	test("rejects non-existent file under symlink-escaped parent", () => {
318		// Parent is a symlink pointing outside β€” even though target file doesn't exist
319		expect(() =>
320			ensureWorkspacePath(symlinkWorkspace, "escape-link/new-file.txt"),
321		).toThrow(/escapes workspace via symlink/);
322	});
323
324	test("writeWorkspaceFile rejects path through symlink escape", async () => {
325		await expect(
326			writeWorkspaceFile(symlinkWorkspace, "escape-link/evil.txt", "pwned"),
327		).rejects.toThrow(/escapes workspace via symlink/);
328	});
329
330	test("writeWorkspaceFile allows normal nested write", async () => {
331		const result = await writeWorkspaceFile(symlinkWorkspace, "new-dir/file.txt", "ok");
332		expect(result.filePath).toBe(join(symlinkWorkspace, "new-dir", "file.txt"));
333	});
334});