1import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join, resolve } from "node:path";
5import { ensureWorkspacePath } from "../src/agent/tools/index.js";
6import { expandPath, resolveToCwd, resolveReadPath } from "../src/agent/tools/path-utils.js";
7import { writeWorkspaceFile } from "../src/workspace/content.js";
8
9let workspace: string;
10
11beforeAll(async () => {
12 workspace = await mkdtemp(join(tmpdir(), "rumilo-test-"));
13 await mkdir(join(workspace, "subdir"), { recursive: true });
14 await writeFile(join(workspace, "hello.txt"), "hello");
15 await writeFile(join(workspace, "subdir", "nested.txt"), "nested");
16});
17
18afterAll(async () => {
19 await rm(workspace, { recursive: true, force: true });
20});
21
22// βββ ensureWorkspacePath ββββββββββββββββββββββββββββββββββββββββββββ
23
24describe("ensureWorkspacePath", () => {
25 test("allows workspace root itself", () => {
26 const result = ensureWorkspacePath(workspace, ".");
27 expect(result).toBe(workspace);
28 });
29
30 test("allows a relative child path", () => {
31 const result = ensureWorkspacePath(workspace, "hello.txt");
32 expect(result).toBe(join(workspace, "hello.txt"));
33 });
34
35 test("allows nested relative path", () => {
36 const result = ensureWorkspacePath(workspace, "subdir/nested.txt");
37 expect(result).toBe(join(workspace, "subdir", "nested.txt"));
38 });
39
40 test("rejects .. traversal escaping workspace", () => {
41 expect(() => ensureWorkspacePath(workspace, "../../../etc/passwd")).toThrow("Path escapes workspace");
42 });
43
44 test("rejects absolute path outside workspace", () => {
45 expect(() => ensureWorkspacePath(workspace, "/etc/passwd")).toThrow("Path escapes workspace");
46 });
47
48 test("allows absolute path inside workspace", () => {
49 const absInside = join(workspace, "hello.txt");
50 const result = ensureWorkspacePath(workspace, absInside);
51 expect(result).toBe(absInside);
52 });
53});
54
55// βββ expandPath: tilde must NOT escape workspace ββββββββββββββββββββ
56
57describe("expandPath - tilde handling for workspace sandboxing", () => {
58 test("tilde alone must not expand to homedir", () => {
59 const result = expandPath("~");
60 // After fix, ~ should remain literal (not expand to homedir)
61 expect(result).toBe("~");
62 });
63
64 test("tilde-prefixed path must not expand to homedir", () => {
65 const result = expandPath("~/secret");
66 expect(result).not.toContain("/home");
67 expect(result).not.toContain("/Users");
68 // Should stay as literal path
69 expect(result).toBe("~/secret");
70 });
71});
72
73// βββ resolveToCwd: must stay within workspace βββββββββββββββββββββββ
74
75describe("resolveToCwd - workspace containment", () => {
76 test("resolves relative path within workspace", () => {
77 const result = resolveToCwd("hello.txt", workspace);
78 expect(result).toBe(join(workspace, "hello.txt"));
79 });
80
81 test("resolves '.' to workspace root", () => {
82 const result = resolveToCwd(".", workspace);
83 expect(result).toBe(workspace);
84 });
85});
86
87// βββ Tool-level containment (read tool) βββββββββββββββββββββββββββββ
88
89describe("read tool - workspace containment", () => {
90 let readTool: any;
91
92 beforeAll(async () => {
93 const { createReadTool } = await import("../src/agent/tools/read.js");
94 readTool = createReadTool(workspace);
95 });
96
97 test("reads file inside workspace", async () => {
98 const result = await readTool.execute("id", { path: "hello.txt" });
99 expect(result.content[0].text).toBe("hello");
100 });
101
102 test("rejects traversal via ..", async () => {
103 await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow(
104 /escapes workspace/i,
105 );
106 });
107
108 test("rejects absolute path outside workspace", async () => {
109 await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow(
110 /escapes workspace/i,
111 );
112 });
113
114 test("tilde path stays within workspace (no homedir expansion)", async () => {
115 // With tilde expansion removed, ~/foo resolves to <workspace>/~/foo
116 // which is safely inside the workspace. It will fail with ENOENT,
117 // NOT succeed in reading the user's homedir file.
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 // path.join(workspace, "/tmp/evil.txt") => "<workspace>/tmp/evil.txt"
227 // This is actually inside the workspace β join concatenates, doesn't replace.
228 const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe");
229 expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt"));
230 });
231
232 test("tilde path via join stays inside workspace", async () => {
233 // With no tilde expansion, ~/evil.txt joins as <workspace>/~/evil.txt
234 const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe");
235 expect(result.filePath).toBe(join(workspace, "~", "evil.txt"));
236 });
237});