Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions frontend/src/features/agent/fs-store.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
Expand All @@ -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(),
Expand All @@ -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) {
Expand Down
123 changes: 123 additions & 0 deletions tests/frontend/agent-fs-root-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
});
});