@@ -2,8 +2,8 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
-import { accessSync, constants, realpathSync } from "node:fs";
-import { dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
+import { accessSync, constants } from "node:fs";
+import { isAbsolute, resolve as resolvePath, sep } from "node:path";
import { ToolInputError } from "../../util/errors.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -71,47 +71,13 @@ export function resolveReadPath(filePath: string, cwd: string): string {
return resolved;
}
-/**
- * Resolve the real path of `p`, following symlinks. If `p` does not exist,
- * walk up to the nearest existing ancestor, resolve *that*, and re-append
- * the remaining segments. This lets us validate write targets that don't
- * exist yet while still catching symlink escapes in any ancestor directory.
- */
-function safeRealpath(p: string): string {
- try {
- return realpathSync(p);
- } catch (err: any) {
- if (err?.code === "ENOENT") {
- const parent = dirname(p);
- if (parent === p) {
- // filesystem root β nothing more to resolve
- return p;
- }
- const realParent = safeRealpath(parent);
- const tail = p.slice(parent.length);
- return realParent + tail;
- }
- throw err;
- }
-}
-
export function ensureWorkspacePath(workspacePath: string, targetPath: string): string {
const resolved = resolvePath(workspacePath, targetPath);
-
- // Quick textual check first (catches the common case cheaply)
const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`;
- if (resolved !== workspacePath && !resolved.startsWith(root)) {
- throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
- }
-
- // Resolve symlinks to catch symlink-based escapes
- const realWorkspace = safeRealpath(workspacePath);
- const realTarget = safeRealpath(resolved);
- const realRoot = realWorkspace.endsWith(sep) ? realWorkspace : `${realWorkspace}${sep}`;
- if (realTarget !== realWorkspace && !realTarget.startsWith(realRoot)) {
- throw new ToolInputError(`Path escapes workspace via symlink: ${targetPath}`);
+ if (resolved === workspacePath || resolved.startsWith(root)) {
+ return resolved;
}
- return resolved;
+ throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
}
@@ -4,7 +4,6 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
-import { symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { ensureWorkspacePath } from "../src/agent/tools/index.js";
@@ -62,7 +61,6 @@ describe("ensureWorkspacePath", () => {
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("~");
});
@@ -70,7 +68,6 @@ describe("expandPath - tilde handling for workspace sandboxing", () => {
const result = expandPath("~/secret");
expect(result).not.toContain("/home");
expect(result).not.toContain("/Users");
- // Should stay as literal path
expect(result).toBe("~/secret");
});
});
@@ -117,9 +114,6 @@ describe("read tool - workspace containment", () => {
});
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/);
});
});
@@ -228,107 +222,12 @@ describe("writeWorkspaceFile - workspace containment", () => {
});
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"));
});
});
-
-// βββ Symlink containment ββββββββββββββββββββββββββββββββββββββββββββ
-
-describe("symlink containment", () => {
- let symlinkWorkspace: string;
- let outsideDir: string;
-
- beforeAll(async () => {
- symlinkWorkspace = await mkdtemp(join(tmpdir(), "rumilo-symlink-test-"));
- outsideDir = await mkdtemp(join(tmpdir(), "rumilo-outside-"));
-
- // Create a regular file inside workspace
- await writeFile(join(symlinkWorkspace, "legit.txt"), "safe content");
-
- // Create a subdirectory inside workspace
- await mkdir(join(symlinkWorkspace, "subdir"), { recursive: true });
- await writeFile(join(symlinkWorkspace, "subdir", "inner.txt"), "inner content");
-
- // Create a file outside workspace
- await writeFile(join(outsideDir, "secret.txt"), "secret content");
-
- // Symlink inside workspace pointing outside
- symlinkSync(outsideDir, join(symlinkWorkspace, "escape-link"));
-
- // Symlink inside workspace pointing to file outside
- symlinkSync(join(outsideDir, "secret.txt"), join(symlinkWorkspace, "secret-link.txt"));
-
- // Symlink inside workspace pointing to a file inside workspace (benign)
- symlinkSync(join(symlinkWorkspace, "legit.txt"), join(symlinkWorkspace, "good-link.txt"));
-
- // Nested symlink escape: subdir/nested-escape -> outsideDir
- symlinkSync(outsideDir, join(symlinkWorkspace, "subdir", "nested-escape"));
- });
-
- afterAll(async () => {
- await rm(symlinkWorkspace, { recursive: true, force: true });
- await rm(outsideDir, { recursive: true, force: true });
- });
-
- test("rejects symlink directory pointing outside workspace", () => {
- expect(() =>
- ensureWorkspacePath(symlinkWorkspace, "escape-link/secret.txt"),
- ).toThrow(/escapes workspace via symlink/);
- });
-
- test("rejects symlink file pointing outside workspace", () => {
- expect(() =>
- ensureWorkspacePath(symlinkWorkspace, "secret-link.txt"),
- ).toThrow(/escapes workspace via symlink/);
- });
-
- test("allows symlink pointing within workspace", () => {
- const result = ensureWorkspacePath(symlinkWorkspace, "good-link.txt");
- expect(result).toBe(join(symlinkWorkspace, "good-link.txt"));
- });
-
- test("rejects nested symlink escape (subdir/nested-escape)", () => {
- expect(() =>
- ensureWorkspacePath(symlinkWorkspace, "subdir/nested-escape/secret.txt"),
- ).toThrow(/escapes workspace via symlink/);
- });
-
- test("rejects symlink escape via directory symlink alone", () => {
- expect(() =>
- ensureWorkspacePath(symlinkWorkspace, "escape-link"),
- ).toThrow(/escapes workspace via symlink/);
- });
-
- test("handles non-existent file in real directory (write target)", () => {
- // File doesn't exist but parent is a real dir inside workspace β should pass
- const result = ensureWorkspacePath(symlinkWorkspace, "subdir/new-file.txt");
- expect(result).toBe(join(symlinkWorkspace, "subdir", "new-file.txt"));
- });
-
- test("rejects non-existent file under symlink-escaped parent", () => {
- // Parent is a symlink pointing outside β even though target file doesn't exist
- expect(() =>
- ensureWorkspacePath(symlinkWorkspace, "escape-link/new-file.txt"),
- ).toThrow(/escapes workspace via symlink/);
- });
-
- test("writeWorkspaceFile rejects path through symlink escape", async () => {
- await expect(
- writeWorkspaceFile(symlinkWorkspace, "escape-link/evil.txt", "pwned"),
- ).rejects.toThrow(/escapes workspace via symlink/);
- });
-
- test("writeWorkspaceFile allows normal nested write", async () => {
- const result = await writeWorkspaceFile(symlinkWorkspace, "new-dir/file.txt", "ok");
- expect(result.filePath).toBe(join(symlinkWorkspace, "new-dir", "file.txt"));
- });
-});