fix: confine Agent and MCP data access

This commit is contained in:
2026-07-10 16:38:08 +08:00
parent 73fa28a178
commit f4968d6657
25 changed files with 1521 additions and 221 deletions
+21 -52
View File
@@ -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;
}
}