forked from bai/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { extname, isAbsolute, join } from "node:path";
|
||||
import {
|
||||
WorkspaceFileBoundaryError,
|
||||
readWorkspaceFileNoFollow,
|
||||
} from "../security/workspaceFiles.js";
|
||||
|
||||
export interface DeliverableFile {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
const DELIVERABLE_EXTENSIONS = new Set([
|
||||
@@ -28,6 +32,7 @@ const DELIVERABLE_EXTENSIONS = new Set([
|
||||
|
||||
export async function resolveDeliverableFile(
|
||||
requestedPath: string,
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
): Promise<DeliverableFile | null> {
|
||||
const token = requestedPath.trim();
|
||||
@@ -35,61 +40,25 @@ export async function resolveDeliverableFile(
|
||||
return null;
|
||||
}
|
||||
|
||||
const roots = await allowedRoots(workspaceDir);
|
||||
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
|
||||
if (!isAllowedPath(candidate, roots)) continue;
|
||||
const existing = await existingFile(candidate);
|
||||
if (existing !== null) return { path: existing, name: basename(existing) };
|
||||
for (const candidate of candidatePaths(token)) {
|
||||
try {
|
||||
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate);
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
|
||||
throw error;
|
||||
}
|
||||
// Try the explicit workspace/build fallback for a bare filename. No
|
||||
// candidate outside the project workspace is ever considered.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function allowedRoots(workspaceDir: string): Promise<string[]> {
|
||||
const roots = [resolve(workspaceDir)];
|
||||
const gitRoot = await findGitRoot(workspaceDir);
|
||||
if (gitRoot !== undefined) roots.push(gitRoot);
|
||||
return [...new Set(roots)];
|
||||
}
|
||||
|
||||
async function findGitRoot(startDir: string): Promise<string | undefined> {
|
||||
let current = resolve(startDir);
|
||||
while (true) {
|
||||
try {
|
||||
await access(join(current, ".git"));
|
||||
return current;
|
||||
} catch {
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
|
||||
if (isAbsolute(token)) return [resolve(token)];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const root of roots) {
|
||||
candidates.push(resolve(root, token));
|
||||
}
|
||||
function candidatePaths(token: string): string[] {
|
||||
if (isAbsolute(token)) return [token];
|
||||
const candidates = [token];
|
||||
if (!token.includes("/") && !token.includes("\\")) {
|
||||
candidates.push(resolve(workspaceDir, "build", token));
|
||||
candidates.push(join("build", token));
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
|
||||
async function existingFile(path: string): Promise<string | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? resolve(path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user