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