forked from EduCraft/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
export class WorkspaceFileBoundaryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly requestedPath: string,
|
||||
readonly reason: "not_found" | "boundary" | "io" = "boundary",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "WorkspaceFileBoundaryError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceFileSnapshot {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
interface OpenDirectory {
|
||||
readonly handle: FileHandle;
|
||||
readonly displayPath: string;
|
||||
}
|
||||
|
||||
/** Read a stable snapshot without following any symlink below the workspace. */
|
||||
export async function readWorkspaceFileNoFollow(
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
): Promise<WorkspaceFileSnapshot> {
|
||||
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||
const components = fileComponents(workspace, requestedPath);
|
||||
const name = components.at(-1)!;
|
||||
const directories = components.slice(0, -1);
|
||||
const parent = await openDirectoryTree(workspace, directories, false, requestedPath);
|
||||
const openedPath = childPath(parent, name);
|
||||
let file: FileHandle | undefined;
|
||||
let result: WorkspaceFileSnapshot | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
file = await open(openedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const metadata = await file.stat();
|
||||
if (!metadata.isFile()) {
|
||||
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
|
||||
}
|
||||
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
||||
const data = await file.readFile();
|
||||
result = { path: join(workspace, ...components), name, data };
|
||||
} catch (error) {
|
||||
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
|
||||
}
|
||||
await finishWithCleanup(
|
||||
failure,
|
||||
[file, parent.handle],
|
||||
`workspace file read cleanup failed: ${requestedPath}`,
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one inbound file exclusively below the workspace and stream into its
|
||||
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
|
||||
* concurrent directory rename/symlink swap cannot redirect the write.
|
||||
*/
|
||||
export async function writeNewWorkspaceFileNoFollow(
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
source: Readable,
|
||||
): Promise<string> {
|
||||
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||
const components = fileComponents(workspace, requestedPath);
|
||||
const name = components.at(-1)!;
|
||||
const directories = components.slice(0, -1);
|
||||
const parent = await openDirectoryTree(workspace, directories, true, requestedPath);
|
||||
const openedPath = childPath(parent, name);
|
||||
let file: FileHandle | undefined;
|
||||
let device: number | undefined;
|
||||
let inode: number | undefined;
|
||||
let result: string | undefined;
|
||||
let failure: unknown;
|
||||
const cleanupFailures: unknown[] = [];
|
||||
try {
|
||||
file = await open(
|
||||
openedPath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const metadata = await file.stat();
|
||||
device = metadata.dev;
|
||||
inode = metadata.ino;
|
||||
for await (const chunk of source) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null);
|
||||
if (bytesWritten === 0) throw new Error("inbound workspace write made no progress");
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
await file.sync();
|
||||
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
||||
result = join(workspace, ...components);
|
||||
} catch (error) {
|
||||
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
|
||||
if (device !== undefined && inode !== undefined) {
|
||||
try {
|
||||
await unlinkIfSameFile(parent, name, device, inode);
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
await finishWithCleanup(
|
||||
failure,
|
||||
[file, parent.handle],
|
||||
`inbound workspace file cleanup failed: ${requestedPath}`,
|
||||
cleanupFailures,
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
|
||||
if (process.platform !== "linux") {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
"race-safe descriptor-relative workspace access requires Linux",
|
||||
workspaceDir,
|
||||
);
|
||||
}
|
||||
if (!isAbsolute(workspaceRoot) || !isAbsolute(workspaceDir)) {
|
||||
throw new WorkspaceFileBoundaryError("workspace root and project path must be absolute", workspaceDir);
|
||||
}
|
||||
const configuredRoot = resolve(workspaceRoot);
|
||||
const configuredWorkspace = resolve(workspaceDir);
|
||||
const rel = relative(configuredRoot, configuredWorkspace);
|
||||
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new WorkspaceFileBoundaryError("project workspace escapes configured workspace root", workspaceDir);
|
||||
}
|
||||
const rootMetadata = await lstat(configuredRoot);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new WorkspaceFileBoundaryError("configured workspace root is not a real directory", workspaceRoot);
|
||||
}
|
||||
const root = await realpath(configuredRoot);
|
||||
const components = rel.split(sep);
|
||||
const openedWorkspace = await openDirectoryTree(root, components, false, workspaceDir);
|
||||
let result: string | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
const openedTarget = await realpath(`/proc/self/fd/${openedWorkspace.handle.fd}`);
|
||||
const expected = join(root, ...components);
|
||||
if (openedTarget !== expected) {
|
||||
throw new WorkspaceFileBoundaryError("project workspace contains a symlink", workspaceDir);
|
||||
}
|
||||
result = expected;
|
||||
} catch (error) {
|
||||
failure = boundaryError(error, workspaceDir, "project workspace validation failed");
|
||||
}
|
||||
await finishWithCleanup(
|
||||
failure,
|
||||
[openedWorkspace.handle],
|
||||
`project workspace validation cleanup failed: ${workspaceDir}`,
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
function fileComponents(root: string, requestedPath: string): string[] {
|
||||
const token = requestedPath.trim();
|
||||
if (token === "" || token.includes("\0")) {
|
||||
throw new WorkspaceFileBoundaryError("workspace file path is empty or invalid", requestedPath);
|
||||
}
|
||||
const absolute = isAbsolute(token) ? resolve(token) : resolve(root, token);
|
||||
const rel = relative(root, absolute);
|
||||
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new WorkspaceFileBoundaryError(`workspace file path escapes current project: ${requestedPath}`, requestedPath);
|
||||
}
|
||||
const components = rel.split(/[\\/]/);
|
||||
if (components.some((component) => component === "" || component === "." || component === "..")) {
|
||||
throw new WorkspaceFileBoundaryError(`workspace file path is not normalized: ${requestedPath}`, requestedPath);
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
async function openDirectoryTree(
|
||||
root: string,
|
||||
components: readonly string[],
|
||||
create: boolean,
|
||||
requestedPath: string,
|
||||
): Promise<OpenDirectory> {
|
||||
let current: OpenDirectory = {
|
||||
handle: await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW),
|
||||
displayPath: root,
|
||||
};
|
||||
try {
|
||||
for (const component of components) {
|
||||
const path = childPath(current, component);
|
||||
let next: FileHandle;
|
||||
try {
|
||||
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
} catch (error) {
|
||||
if (!create || !isErrno(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(path, { mode: 0o700 });
|
||||
} catch (mkdirError) {
|
||||
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
}
|
||||
const previous = current;
|
||||
current = { handle: next, displayPath: join(previous.displayPath, component) };
|
||||
await previous.handle.close();
|
||||
}
|
||||
return current;
|
||||
} catch (error) {
|
||||
try {
|
||||
await current.handle.close();
|
||||
} catch (closeError) {
|
||||
throw new AggregateError(
|
||||
[error, closeError],
|
||||
`workspace path validation and directory close both failed: ${requestedPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw boundaryError(error, requestedPath, "workspace path contains a symlink or non-directory component");
|
||||
}
|
||||
}
|
||||
|
||||
function childPath(parent: OpenDirectory, name: string): string {
|
||||
return `/proc/self/fd/${parent.handle.fd}/${name}`;
|
||||
}
|
||||
|
||||
async function assertNameStillReferences(
|
||||
parent: OpenDirectory,
|
||||
name: string,
|
||||
device: number,
|
||||
inode: number,
|
||||
requestedPath: string,
|
||||
): Promise<void> {
|
||||
const metadata = await lstat(childPath(parent, name));
|
||||
if (metadata.isSymbolicLink() || metadata.dev !== device || metadata.ino !== inode) {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
`workspace file changed during no-follow access: ${requestedPath}`,
|
||||
requestedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkIfSameFile(parent: OpenDirectory, name: string, device: number, inode: number): Promise<void> {
|
||||
const path = childPath(parent, name);
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isSymbolicLink() && metadata.dev === device && metadata.ino === inode) {
|
||||
await unlink(path);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isErrno(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function boundaryError(error: unknown, requestedPath: string, prefix: string): WorkspaceFileBoundaryError {
|
||||
if (error instanceof WorkspaceFileBoundaryError) return error;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const reason = isErrno(error, "ENOENT")
|
||||
? "not_found"
|
||||
: isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR")
|
||||
? "boundary"
|
||||
: "io";
|
||||
return new WorkspaceFileBoundaryError(
|
||||
`${prefix}: ${requestedPath} (${detail})`,
|
||||
requestedPath,
|
||||
reason,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
async function finishWithCleanup(
|
||||
primaryFailure: unknown,
|
||||
handles: ReadonlyArray<FileHandle | undefined>,
|
||||
message: string,
|
||||
additionalFailures: readonly unknown[] = [],
|
||||
): Promise<void> {
|
||||
const settled = await Promise.allSettled(
|
||||
handles.filter((handle): handle is FileHandle => handle !== undefined).map((handle) => handle.close()),
|
||||
);
|
||||
const cleanupFailures = [
|
||||
...additionalFailures,
|
||||
...settled.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
|
||||
];
|
||||
if (primaryFailure !== undefined && cleanupFailures.length === 0) throw primaryFailure;
|
||||
if (primaryFailure !== undefined || cleanupFailures.length > 0) {
|
||||
const failures = primaryFailure === undefined ? cleanupFailures : [primaryFailure, ...cleanupFailures];
|
||||
throw new AggregateError(failures, message, { cause: primaryFailure ?? cleanupFailures[0] });
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
Reference in New Issue
Block a user