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});