@@ -1,5 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
-import { dirname, join } from "node:path";
+import { dirname, join, resolve, sep } from "node:path";
+import { ToolInputError } from "../util/errors.js";
export interface WorkspaceContent {
filePath: string;
@@ -7,12 +8,22 @@ export interface WorkspaceContent {
bytes: number;
}
+function ensureContained(workspacePath: string, targetPath: string): void {
+ const resolved = resolve(workspacePath, targetPath);
+ const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`;
+
+ if (resolved !== workspacePath && !resolved.startsWith(root)) {
+ throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
+ }
+}
+
export async function writeWorkspaceFile(
workspacePath: string,
relativePath: string,
content: string,
): Promise<WorkspaceContent> {
const filePath = join(workspacePath, relativePath);
+ ensureContained(workspacePath, filePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
@@ -0,0 +1,237 @@
+import { describe, test, expect, beforeAll, afterAll } from "bun:test";
+import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { ensureWorkspacePath } from "../src/agent/tools/index.js";
+import { expandPath, resolveToCwd, resolveReadPath } from "../src/agent/tools/path-utils.js";
+import { writeWorkspaceFile } from "../src/workspace/content.js";
+
+let workspace: string;
+
+beforeAll(async () => {
+ workspace = await mkdtemp(join(tmpdir(), "rumilo-test-"));
+ await mkdir(join(workspace, "subdir"), { recursive: true });
+ await writeFile(join(workspace, "hello.txt"), "hello");
+ await writeFile(join(workspace, "subdir", "nested.txt"), "nested");
+});
+
+afterAll(async () => {
+ await rm(workspace, { recursive: true, force: true });
+});
+
+// βββ ensureWorkspacePath ββββββββββββββββββββββββββββββββββββββββββββ
+
+describe("ensureWorkspacePath", () => {
+ test("allows workspace root itself", () => {
+ const result = ensureWorkspacePath(workspace, ".");
+ expect(result).toBe(workspace);
+ });
+
+ test("allows a relative child path", () => {
+ const result = ensureWorkspacePath(workspace, "hello.txt");
+ expect(result).toBe(join(workspace, "hello.txt"));
+ });
+
+ test("allows nested relative path", () => {
+ const result = ensureWorkspacePath(workspace, "subdir/nested.txt");
+ expect(result).toBe(join(workspace, "subdir", "nested.txt"));
+ });
+
+ test("rejects .. traversal escaping workspace", () => {
+ expect(() => ensureWorkspacePath(workspace, "../../../etc/passwd")).toThrow("Path escapes workspace");
+ });
+
+ test("rejects absolute path outside workspace", () => {
+ expect(() => ensureWorkspacePath(workspace, "/etc/passwd")).toThrow("Path escapes workspace");
+ });
+
+ test("allows absolute path inside workspace", () => {
+ const absInside = join(workspace, "hello.txt");
+ const result = ensureWorkspacePath(workspace, absInside);
+ expect(result).toBe(absInside);
+ });
+});
+
+// βββ expandPath: tilde must NOT escape workspace ββββββββββββββββββββ
+
+describe("expandPath - tilde handling for workspace sandboxing", () => {
+ test("tilde alone must not expand to homedir", () => {
+ const result = expandPath("~");
+ // After fix, ~ should remain literal (not expand to homedir)
+ expect(result).toBe("~");
+ });
+
+ test("tilde-prefixed path must not expand to homedir", () => {
+ const result = expandPath("~/secret");
+ expect(result).not.toContain("/home");
+ expect(result).not.toContain("/Users");
+ // Should stay as literal path
+ expect(result).toBe("~/secret");
+ });
+});
+
+// βββ resolveToCwd: must stay within workspace βββββββββββββββββββββββ
+
+describe("resolveToCwd - workspace containment", () => {
+ test("resolves relative path within workspace", () => {
+ const result = resolveToCwd("hello.txt", workspace);
+ expect(result).toBe(join(workspace, "hello.txt"));
+ });
+
+ test("resolves '.' to workspace root", () => {
+ const result = resolveToCwd(".", workspace);
+ expect(result).toBe(workspace);
+ });
+});
+
+// βββ Tool-level containment (read tool) βββββββββββββββββββββββββββββ
+
+describe("read tool - workspace containment", () => {
+ let readTool: any;
+
+ beforeAll(async () => {
+ const { createReadTool } = await import("../src/agent/tools/read.js");
+ readTool = createReadTool(workspace);
+ });
+
+ test("reads file inside workspace", async () => {
+ const result = await readTool.execute("id", { path: "hello.txt" });
+ expect(result.content[0].text).toBe("hello");
+ });
+
+ test("rejects traversal via ..", async () => {
+ await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow(
+ /escapes workspace/i,
+ );
+ });
+
+ test("rejects absolute path outside workspace", async () => {
+ await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow(
+ /escapes workspace/i,
+ );
+ });
+
+ test("tilde path stays within workspace (no homedir expansion)", async () => {
+ // With tilde expansion removed, ~/foo resolves to <workspace>/~/foo
+ // which is safely inside the workspace. It will fail with ENOENT,
+ // NOT succeed in reading the user's homedir file.
+ await expect(readTool.execute("id", { path: "~/.bashrc" })).rejects.toThrow(/ENOENT/);
+ });
+});
+
+// βββ Tool-level containment (ls tool) βββββββββββββββββββββββββββββββ
+
+describe("ls tool - workspace containment", () => {
+ let lsTool: any;
+
+ beforeAll(async () => {
+ const { createLsTool } = await import("../src/agent/tools/ls.js");
+ lsTool = createLsTool(workspace);
+ });
+
+ test("lists workspace root", async () => {
+ const result = await lsTool.execute("id", {});
+ expect(result.content[0].text).toContain("hello.txt");
+ });
+
+ test("rejects traversal via ..", async () => {
+ await expect(lsTool.execute("id", { path: "../../" })).rejects.toThrow(
+ /escapes workspace/i,
+ );
+ });
+
+ test("rejects absolute path outside workspace", async () => {
+ await expect(lsTool.execute("id", { path: "/tmp" })).rejects.toThrow(
+ /escapes workspace/i,
+ );
+ });
+});
+
+// βββ Tool-level containment (grep tool) βββββββββββββββββββββββββββββ
+
+describe("grep tool - workspace containment", () => {
+ let grepTool: any;
+
+ beforeAll(async () => {
+ const { createGrepTool } = await import("../src/agent/tools/grep.js");
+ grepTool = createGrepTool(workspace);
+ });
+
+ test("searches within workspace", async () => {
+ const result = await grepTool.execute("id", { pattern: "hello", literal: true });
+ expect(result.content[0].text).toContain("hello");
+ });
+
+ test("rejects traversal via ..", async () => {
+ await expect(
+ grepTool.execute("id", { pattern: "root", path: "../../etc" }),
+ ).rejects.toThrow(/escapes workspace/i);
+ });
+
+ test("rejects absolute path outside workspace", async () => {
+ await expect(
+ grepTool.execute("id", { pattern: "root", path: "/etc" }),
+ ).rejects.toThrow(/escapes workspace/i);
+ });
+});
+
+// βββ Tool-level containment (find tool) βββββββββββββββββββββββββββββ
+
+describe("find tool - workspace containment", () => {
+ let findTool: any;
+
+ beforeAll(async () => {
+ const { createFindTool } = await import("../src/agent/tools/find.js");
+ findTool = createFindTool(workspace);
+ });
+
+ test("finds files in workspace", async () => {
+ const result = await findTool.execute("id", { pattern: "*.txt" });
+ expect(result.content[0].text).toContain("hello.txt");
+ });
+
+ test("rejects traversal via ..", async () => {
+ await expect(
+ findTool.execute("id", { pattern: "*", path: "../../" }),
+ ).rejects.toThrow(/escapes workspace/i);
+ });
+
+ test("rejects absolute path outside workspace", async () => {
+ await expect(
+ findTool.execute("id", { pattern: "*", path: "/tmp" }),
+ ).rejects.toThrow(/escapes workspace/i);
+ });
+});
+
+// βββ writeWorkspaceFile containment (Issue #4) ββββββββββββββββββββββ
+
+describe("writeWorkspaceFile - workspace containment", () => {
+ test("writes file inside workspace", async () => {
+ const result = await writeWorkspaceFile(workspace, "output.txt", "data");
+ expect(result.filePath).toBe(join(workspace, "output.txt"));
+ });
+
+ test("writes nested file inside workspace", async () => {
+ const result = await writeWorkspaceFile(workspace, "a/b/c.txt", "deep");
+ expect(result.filePath).toBe(join(workspace, "a", "b", "c.txt"));
+ });
+
+ test("rejects traversal via ..", async () => {
+ await expect(
+ writeWorkspaceFile(workspace, "../../../tmp/evil.txt", "pwned"),
+ ).rejects.toThrow(/escapes workspace/i);
+ });
+
+ test("absolute path via join stays inside workspace", async () => {
+ // path.join(workspace, "/tmp/evil.txt") => "<workspace>/tmp/evil.txt"
+ // This is actually inside the workspace β join concatenates, doesn't replace.
+ const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe");
+ expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt"));
+ });
+
+ test("tilde path via join stays inside workspace", async () => {
+ // With no tilde expansion, ~/evil.txt joins as <workspace>/~/evil.txt
+ const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe");
+ expect(result.filePath).toBe(join(workspace, "~", "evil.txt"));
+ });
+});