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		expect(result).toBe("~");
 66	});
 67
 68	test("tilde-prefixed path must not expand to homedir", () => {
 69		const result = expandPath("~/secret");
 70		expect(result).not.toContain("/home");
 71		expect(result).not.toContain("/Users");
 72		expect(result).toBe("~/secret");
 73	});
 74});
 75
 76// ─── resolveToCwd: must stay within workspace ───────────────────────
 77
 78describe("resolveToCwd - workspace containment", () => {
 79	test("resolves relative path within workspace", () => {
 80		const result = resolveToCwd("hello.txt", workspace);
 81		expect(result).toBe(join(workspace, "hello.txt"));
 82	});
 83
 84	test("resolves '.' to workspace root", () => {
 85		const result = resolveToCwd(".", workspace);
 86		expect(result).toBe(workspace);
 87	});
 88});
 89
 90// ─── Tool-level containment (read tool) ─────────────────────────────
 91
 92describe("read tool - workspace containment", () => {
 93	let readTool: any;
 94
 95	beforeAll(async () => {
 96		const { createReadTool } = await import("../src/agent/tools/read.js");
 97		readTool = createReadTool(workspace);
 98	});
 99
100	test("reads file inside workspace", async () => {
101		const result = await readTool.execute("id", { path: "hello.txt" });
102		expect(result.content[0].text).toBe("hello");
103	});
104
105	test("rejects traversal via ..", async () => {
106		await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow(
107			/escapes workspace/i,
108		);
109	});
110
111	test("rejects absolute path outside workspace", async () => {
112		await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow(
113			/escapes workspace/i,
114		);
115	});
116
117	test("tilde path stays within workspace (no homedir expansion)", async () => {
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		const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe");
227		expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt"));
228	});
229
230	test("tilde path via join stays inside workspace", async () => {
231		const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe");
232		expect(result.filePath).toBe(join(workspace, "~", "evil.txt"));
233	});
234});
235
236// ─── Symlink containment ────────────────────────────────────────────
237
238describe("symlink containment", () => {
239	let symlinkWorkspace: string;
240	let outsideDir: string;
241
242	beforeAll(async () => {
243		symlinkWorkspace = await mkdtemp(join(tmpdir(), "rumilo-symlink-test-"));
244		outsideDir = await mkdtemp(join(tmpdir(), "rumilo-outside-"));
245
246		// Create a regular file inside workspace
247		await writeFile(join(symlinkWorkspace, "legit.txt"), "safe content");
248
249		// Create a subdirectory inside workspace
250		await mkdir(join(symlinkWorkspace, "subdir"), { recursive: true });
251		await writeFile(join(symlinkWorkspace, "subdir", "inner.txt"), "inner content");
252
253		// Create a file outside workspace
254		await writeFile(join(outsideDir, "secret.txt"), "secret content");
255
256		// Symlink inside workspace pointing outside
257		symlinkSync(outsideDir, join(symlinkWorkspace, "escape-link"));
258
259		// Symlink inside workspace pointing to file outside
260		symlinkSync(join(outsideDir, "secret.txt"), join(symlinkWorkspace, "secret-link.txt"));
261
262		// Symlink inside workspace pointing to a file inside workspace (benign)
263		symlinkSync(join(symlinkWorkspace, "legit.txt"), join(symlinkWorkspace, "good-link.txt"));
264
265		// Nested symlink escape: subdir/nested-escape -> outsideDir
266		symlinkSync(outsideDir, join(symlinkWorkspace, "subdir", "nested-escape"));
267	});
268
269	afterAll(async () => {
270		await rm(symlinkWorkspace, { recursive: true, force: true });
271		await rm(outsideDir, { recursive: true, force: true });
272	});
273
274	test("rejects symlink directory pointing outside workspace", () => {
275		expect(() =>
276			ensureWorkspacePath(symlinkWorkspace, "escape-link/secret.txt"),
277		).toThrow(/escapes workspace via symlink/);
278	});
279
280	test("rejects symlink file pointing outside workspace", () => {
281		expect(() =>
282			ensureWorkspacePath(symlinkWorkspace, "secret-link.txt"),
283		).toThrow(/escapes workspace via symlink/);
284	});
285
286	test("allows symlink pointing within workspace", () => {
287		const result = ensureWorkspacePath(symlinkWorkspace, "good-link.txt");
288		expect(result).toBe(join(symlinkWorkspace, "good-link.txt"));
289	});
290
291	test("rejects nested symlink escape (subdir/nested-escape)", () => {
292		expect(() =>
293			ensureWorkspacePath(symlinkWorkspace, "subdir/nested-escape/secret.txt"),
294		).toThrow(/escapes workspace via symlink/);
295	});
296
297	test("rejects symlink escape via directory symlink alone", () => {
298		expect(() =>
299			ensureWorkspacePath(symlinkWorkspace, "escape-link"),
300		).toThrow(/escapes workspace via symlink/);
301	});
302
303	test("handles non-existent file in real directory (write target)", () => {
304		// File doesn't exist but parent is a real dir inside workspace β€” should pass
305		const result = ensureWorkspacePath(symlinkWorkspace, "subdir/new-file.txt");
306		expect(result).toBe(join(symlinkWorkspace, "subdir", "new-file.txt"));
307	});
308
309	test("rejects non-existent file under symlink-escaped parent", () => {
310		// Parent is a symlink pointing outside β€” even though target file doesn't exist
311		expect(() =>
312			ensureWorkspacePath(symlinkWorkspace, "escape-link/new-file.txt"),
313		).toThrow(/escapes workspace via symlink/);
314	});
315
316	test("writeWorkspaceFile rejects path through symlink escape", async () => {
317		await expect(
318			writeWorkspaceFile(symlinkWorkspace, "escape-link/evil.txt", "pwned"),
319		).rejects.toThrow(/escapes workspace via symlink/);
320	});
321
322	test("writeWorkspaceFile allows normal nested write", async () => {
323		const result = await writeWorkspaceFile(symlinkWorkspace, "new-dir/file.txt", "ok");
324		expect(result.filePath).toBe(join(symlinkWorkspace, "new-dir", "file.txt"));
325	});
326});