From ed3a0e9c468aa128e93fc7c6657d035bd5a5c104 Mon Sep 17 00:00:00 2001 From: terp Date: Mon, 22 Jun 2026 10:05:13 -0400 Subject: [PATCH] fix(frontend): enforce registered agent filesystem roots - Agent FS list/read now resolves the caller-supplied cwd against server-known registered project roots. - Reject cwd values that are not registered project roots. - Canonicalize registered roots and requested targets with realpath before containment checks. - Reject traversal and symlink escapes outside the resolved root. - Legitimate reads/lists inside registered projects continue to work. --- frontend/src/features/agent/fs-store.ts | 30 ++++- tests/frontend/agent-fs-root-boundary.test.ts | 123 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/frontend/agent-fs-root-boundary.test.ts diff --git a/frontend/src/features/agent/fs-store.ts b/frontend/src/features/agent/fs-store.ts index 1cdafce4..c5596c3a 100644 --- a/frontend/src/features/agent/fs-store.ts +++ b/frontend/src/features/agent/fs-store.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs, readdirSync, realpathSync, statSync } from "node:fs"; import path from "node:path"; import type { FsEntry } from "@/features/agent/filesystem-types"; +import { listProjectsFromStore } from "./projects-store"; const IGNORE_DIRS = new Set([ ".git", @@ -57,6 +58,27 @@ export function assertWorkspaceRoot(rootCwd: string): string { return real; } +function resolveRealPath(candidate: string): string { + try { + return realpathSync(candidate); + } catch { + return path.resolve(candidate); + } +} + +// Trust boundary: agent filesystem list/read may only operate inside project +// roots that are registered server-side. The caller-supplied cwd must resolve +// to the realpath of a known registered project. +function resolveProjectRoot(cwd: string): string { + const requestedReal = resolveRealPath(cwd); + for (const project of listProjectsFromStore()) { + if (!project.exists) continue; + const projectReal = resolveRealPath(project.path); + if (projectReal === requestedReal) return projectReal; + } + throw new Error("cwd is not a registered project root"); +} + // Reject any path that escapes the project root, resolving symlinks on both the // root and the target so a symlink inside the root cannot point outside it. function ensureInside(rootCwd: string, target: string): string { @@ -76,7 +98,8 @@ function ensureInside(rootCwd: string, target: string): string { } export function listDirectory(rootCwd: string, relPath: string): FsEntry[] { - const target = ensureInside(rootCwd, path.resolve(rootCwd, relPath || ".")); + const root = resolveProjectRoot(rootCwd); + const target = ensureInside(root, path.resolve(root, relPath || ".")); if (!existsSync(target)) throw new Error("Not found"); const stats = statSync(target); if (!stats.isDirectory()) throw new Error("Not a directory"); @@ -96,7 +119,7 @@ export function listDirectory(rootCwd: string, relPath: string): FsEntry[] { entries.push({ name, path: abs, - rel: path.relative(rootCwd, abs), + rel: path.relative(root, abs), kind: s.isDirectory() ? "directory" : "file", size: s.isFile() ? s.size : undefined, modifiedAt: s.mtime.toISOString(), @@ -114,7 +137,8 @@ export async function readFileSnippet( relPath: string, maxBytes = 5 * 1024 * 1024, ): Promise<{ content: string; truncated: boolean; size: number }> { - const target = ensureInside(rootCwd, path.resolve(rootCwd, relPath)); + const root = resolveProjectRoot(rootCwd); + const target = ensureInside(root, path.resolve(root, relPath)); const stats = await fs.stat(target); if (!stats.isFile()) throw new Error("Not a file"); if (stats.size > maxBytes) { diff --git a/tests/frontend/agent-fs-root-boundary.test.ts b/tests/frontend/agent-fs-root-boundary.test.ts new file mode 100644 index 00000000..32ac1014 --- /dev/null +++ b/tests/frontend/agent-fs-root-boundary.test.ts @@ -0,0 +1,123 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + listDirectory, + readFileSnippet, +} from "../../frontend/src/features/agent/fs-store"; +import { + addProjectToStore, + removeProjectFromStore, +} from "../../frontend/src/features/agent/projects-store"; + +async function rejectsWith( + fn: () => unknown, + expectedMessage: string, +): Promise { + try { + await fn(); + } catch (error) { + assert.ok(error instanceof Error); + assert.equal((error as Error).message, expectedMessage); + return; + } + throw new Error(`Expected rejection with "${expectedMessage}"`); +} + +describe("agent filesystem root boundary", () => { + let originalProjectsFile: string | undefined; + let projectDir: string; + let projectsFile: string; + let projectId: string; + + beforeEach(() => { + originalProjectsFile = process.env.VLLM_STUDIO_PROJECTS_FILE; + const testDir = mkdtempSync(path.join(tmpdir(), "vllm-studio-agentfs-test-")); + projectDir = path.join(testDir, "project"); + projectsFile = path.join(testDir, "projects.json"); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(path.join(projectDir, "sub")); + writeFileSync(path.join(projectDir, "file.txt"), "hello"); + process.env.VLLM_STUDIO_PROJECTS_FILE = projectsFile; + projectId = addProjectToStore(projectDir).id; + }); + + afterEach(() => { + removeProjectFromStore(projectId); + if (originalProjectsFile === undefined) { + delete process.env.VLLM_STUDIO_PROJECTS_FILE; + } else { + process.env.VLLM_STUDIO_PROJECTS_FILE = originalProjectsFile; + } + rmSync(path.dirname(projectDir), { recursive: true, force: true }); + }); + + it("lists a registered project directory", () => { + const entries = listDirectory(projectDir, ""); + const names = entries.map((entry) => entry.name).sort(); + assert.deepEqual(names, ["file.txt", "sub"]); + }); + + it("reads a file inside a registered project directory", async () => { + const result = await readFileSnippet(projectDir, "file.txt"); + assert.equal(result.content, "hello"); + assert.equal(result.truncated, false); + }); + + it("rejects an unregistered absolute cwd", async () => { + const otherDir = mkdtempSync(path.join(tmpdir(), "vllm-studio-agentfs-other-")); + try { + await rejectsWith( + () => listDirectory(otherDir, ""), + "cwd is not a registered project root", + ); + } finally { + rmSync(otherDir, { recursive: true, force: true }); + } + }); + + it("rejects traversal outside the project root", async () => { + await rejectsWith( + () => listDirectory(projectDir, ".."), + "Path escapes project root", + ); + }); + + it("rejects a symlinked target that escapes the project root", async () => { + const outsideFile = path.join(path.dirname(projectDir), "outside.txt"); + writeFileSync(outsideFile, "secret"); + const linkPath = path.join(projectDir, "link.txt"); + symlinkSync(outsideFile, linkPath); + try { + await rejectsWith( + () => readFileSnippet(projectDir, "link.txt"), + "Path escapes project root", + ); + } finally { + if (existsSync(outsideFile)) rmSync(outsideFile); + } + }); + + it("accepts access through a symlinked project root that resolves inside the real project", () => { + const linkRoot = path.join(path.dirname(projectDir), "project-link"); + symlinkSync(projectDir, linkRoot); + projectId = addProjectToStore(linkRoot).id; + try { + const entries = listDirectory(linkRoot, ""); + const names = entries.map((entry) => entry.name).sort(); + assert.deepEqual(names, ["file.txt", "sub"]); + } finally { + removeProjectFromStore(projectId); + if (existsSync(linkRoot)) rmSync(linkRoot); + } + }); +});