@@ -1,5 +1,5 @@
-import { accessSync, constants } from "node:fs";
-import { isAbsolute, resolve as resolvePath, sep } from "node:path";
+import { accessSync, constants, realpathSync } from "node:fs";
+import { dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
import { ToolInputError } from "../../util/errors.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -67,13 +67,47 @@ 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}`);
+ }
- if (resolved === workspacePath || resolved.startsWith(root)) {
- return resolved;
+ // 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}`);
}
- throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
+ return resolved;
}
@@ -1,5 +1,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";
@@ -235,3 +236,95 @@ describe("writeWorkspaceFile - workspace containment", () => {
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"));
+ });
+});