workspace-containment.test.ts

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