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