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