/** * Workspace file browsing: list directory / read * file (preview & download) / write file (upload). Security: a relative path, once * resolved, must stay inside the Workspace — a logical prefix check plus a realpath * check against the nearest existing ancestor (guards against `..` and symlink escapes). */ import fs from "node:fs/promises"; import { constants as fsc } from "node:fs"; import path from "node:path"; import type { WorkspaceFileEntry, WorkspaceFilesResponse } from "../api/types.js"; import { HttpError } from "../http/errors.js"; import { badRequest } from "../http/validate.js"; /** Per-file read cap (a safety limit since preview/download reads the whole file into memory). */ const MAX_READ_BYTES = 50 * 1024 * 1024; /** Upload cap (stays within the 20MB request body limit even after base64 encoding). */ export const MAX_UPLOAD_BYTES = 14 * 1024 * 1024; const CONTENT_TYPES: Record = { ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8", ".json": "application/json", ".js": "text/javascript; charset=utf-8", ".ts": "text/plain; charset=utf-8", ".tsx": "text/plain; charset=utf-8", ".py": "text/plain; charset=utf-8", ".sh": "text/plain; charset=utf-8", ".yaml": "text/plain; charset=utf-8", ".yml": "text/plain; charset=utf-8", ".toml": "text/plain; charset=utf-8", ".css": "text/css; charset=utf-8", ".csv": "text/plain; charset=utf-8", ".log": "text/plain; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".pdf": "application/pdf", }; export interface WorkspaceFileContent { data: Buffer; fileName: string; contentType: string; /** Types whose same-origin inline rendering would execute scripts (html/svg): inline preview must fall back to plain text. */ scriptable: boolean; /** True when a bounded preview returned only the beginning of the file. */ truncated?: boolean; } export interface WorkspaceFileReadOptions { /** Return at most this many bytes. Used for bounded text previews. */ maxBytes?: number; } export class WorkspaceFilesService { /** Canonical path (realpath) of the Workspace root; 404 if it doesn't exist. */ private async realBase(workspace: string): Promise { try { return await fs.realpath(path.resolve(workspace)); } catch { throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists."); } } /** * Lexical containment check: whether target is inside base (including equal to * base). Uses path.relative rather than prefix concatenation, so it works when * base is the filesystem root ("/" concatenated with sep would produce a "//" * prefix that no subpath could ever match); only a full ".." segment is * compared, so a legitimate name like "..foo" isn't mistakenly rejected. */ private isInside(target: string, base: string): boolean { const rel = path.relative(base, target); return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); } /** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */ private lexicalTarget(base: string, rel: string): string { if (rel.includes("\0")) throw badRequest("path is invalid."); const target = path.resolve(base, rel === "" ? "." : rel); if (!this.isInside(target, base)) { throw badRequest("path must be inside the Workspace."); } return target; } private assertInside(real: string, realBase: string): void { if (!this.isInside(real, realBase)) { throw badRequest("path must be inside the Workspace."); } } /** * Read-path resolution: realpath the entire path (following all symlinks to get * a link-free canonical path), then check containment and **perform IO on the * canonical path** — since the canonical path contains no symlink segments at * all, this eliminates check-then-use TOCTOU escapes (an out-of-bounds symlink * is already resolved and rejected at the realpath step). */ private async resolveRead(workspace: string, rel: string): Promise { const realBase = await this.realBase(workspace); const target = this.lexicalTarget(path.resolve(workspace), rel); let canonical: string; try { canonical = await fs.realpath(target); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { throw new HttpError(404, "path_not_found", "File does not exist."); } throw err; } this.assertInside(canonical, realBase); return canonical; } /** * Write-path resolution: realpaths the parent directory (whose canonical path * has no symlink segments) and checks containment, then appends the final * segment as the file name. When the parent directory is missing, it is safely * created (uploading a folder needs to preserve directory structure): first the * nearest **existing** ancestor is found and its canonical path checked against * the Workspace — this exposes it if a middle segment was preset as a symlink * pointing outside; the missing segments are then created recursively beneath it * (a brand-new directory can never be a symlink), followed by a second realpath * check after creation. The actual write opens with O_NOFOLLOW (refusing to * follow a symlink at the final segment), blocking the sandbox-escape pattern of * "Agent presets a symlink -> an upload is used as leverage to overwrite a file * outside the sandbox". Returns the canonical parent directory + file name. */ private async resolveWriteParent( workspace: string, rel: string, ): Promise<{ dir: string; name: string }> { const realBase = await this.realBase(workspace); const target = this.lexicalTarget(path.resolve(workspace), rel); const name = path.basename(target); if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path."); const parent = path.dirname(target); let canonicalParent: string; try { canonicalParent = await fs.realpath(parent); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; let probe = parent; while (true) { try { this.assertInside(await fs.realpath(probe), realBase); break; } catch (probeErr) { if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr; const up = path.dirname(probe); if (up === probe) throw badRequest("path is invalid."); probe = up; } } await fs.mkdir(parent, { recursive: true }); canonicalParent = await fs.realpath(parent); } this.assertInside(canonicalParent, realBase); return { dir: canonicalParent, name }; } /** * Batch existence check (a message's file card lists only files that actually * exist): each item goes through the same containment resolution as reading * (resolveRead); out-of-bounds, resolution failure, missing Workspace, or an * irregular file are all treated as non-existent — the card scenario only asks * "can this be opened", and throwing a 4xx would only add frontend branches while * leaking containment details. Returns the deduplicated existing items in input order. */ async statExisting(workspace: string, rels: string[]): Promise { const unique = [...new Set(rels)]; const exists = await Promise.all( unique.map(async (rel) => { try { const stat = await fs.stat(await this.resolveRead(workspace, rel)); return stat.isFile(); } catch { return false; } }), ); return unique.filter((_, i) => exists[i]); } /** * List a directory: dirs come first, each group sorted by name. Entries whose * canonical target leaves the Workspace are omitted, so listing cannot expose * metadata for an out-of-bounds symlink. */ async list(workspace: string, rel: string): Promise { const dir = await this.resolveRead(workspace, rel); const realBase = await this.realBase(workspace); let dirents; try { dirents = await fs.readdir(dir, { withFileTypes: true }); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { throw new HttpError(404, "path_not_found", "Directory does not exist."); } if ((err as NodeJS.ErrnoException).code === "ENOTDIR") { throw badRequest("path is not a directory."); } throw err; } const listed = await Promise.all( dirents.map(async (d): Promise => { try { const canonical = await fs.realpath(path.join(dir, d.name)); this.assertInside(canonical, realBase); const stat = await fs.stat(canonical); return { name: d.name, kind: stat.isDirectory() ? "dir" : "file", sizeBytes: stat.size, mtime: stat.mtime.toISOString(), }; } catch { return null; } }), ); const entries = listed.filter((entry): entry is WorkspaceFileEntry => entry !== null); entries.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1, ); return { path: rel, entries }; } /** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */ async read( workspace: string, rel: string, options?: WorkspaceFileReadOptions, ): Promise { const file = await this.resolveRead(workspace, rel); let stat; try { stat = await fs.stat(file); } catch { throw new HttpError(404, "path_not_found", "File does not exist."); } if (stat.isDirectory()) throw badRequest("path is a directory."); const maxBytes = options?.maxBytes; if ( maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES) ) { throw badRequest("maxBytes must be a positive integer within the read limit."); } if (maxBytes === undefined && stat.size > MAX_READ_BYTES) { throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit."); } let data: Buffer; let truncated = false; if (maxBytes !== undefined && stat.size > maxBytes) { const handle = await fs.open(file, "r"); try { const buffer = Buffer.alloc(maxBytes); const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); data = buffer.subarray(0, bytesRead); truncated = true; } finally { await handle.close(); } } else { data = await fs.readFile(file); } const ext = path.extname(file).toLowerCase(); return { data, fileName: path.basename(file), contentType: CONTENT_TYPES[ext] ?? "application/octet-stream", scriptable: ext === ".html" || ext === ".htm" || ext === ".svg", ...(truncated ? { truncated: true } : {}), }; } /** * Write a file (upload, overwriting a same-named one). If the parent directory * is missing, it's automatically created under sandbox checks (preserving * directory structure for folder uploads); the final segment is opened with * O_NOFOLLOW, refusing to follow a symlink to write outside the Workspace * (together with resolveWriteParent's canonical-parent check, this blocks * sandbox escapes). */ async write(workspace: string, rel: string, data: Buffer): Promise { if (rel === "" || rel.endsWith("/")) throw badRequest("path must be a file path."); if (data.length > MAX_UPLOAD_BYTES) { throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit."); } const { dir, name } = await this.resolveWriteParent(workspace, rel); const file = path.join(dir, name); // Windows has no O_NOFOLLOW (the `?? 0` below erases it), so the atomic ELOOP guard never // fires there — refuse a final-segment symlink via lstat instead. Best effort (a link // created between this check and the open wins the race), but it closes the practical // "preset a symlink, overwrite an outside file by upload" escape; POSIX keeps the atomic // open-time guarantee. if (process.platform === "win32") { const st = await fs.lstat(file).catch(() => null); if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink."); } // O_NOFOLLOW: open reports ELOOP if the final segment is a symlink, refusing to use it as leverage to overwrite a file outside the sandbox. const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0); let handle; try { handle = await fs.open(file, flags, 0o644); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ELOOP") throw badRequest("path must not be a symlink."); if (code === "ENOENT") throw new HttpError(404, "path_not_found", "Parent directory does not exist."); if (code === "EISDIR") throw badRequest("path is a directory."); throw err; } try { await handle.writeFile(data); } finally { await handle.close(); } } }