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