Repository Public
1/**2 * Path safety core (runtime-fs). Every filesystem / git / skills / mcp handler resolves the3 * untrusted `rel` it receives through `resolveInside` before touching the disk.4 *5 * Guarantees (unit-tested in paths.test.ts):6 * - `rel` is relative, has no NUL byte, no `..` segment (checked on both separators and after7 * NFKC normalisation), and is not over-long;8 * - `root` realpaths to a directory inside (or equal to) a configured workspace root;9 * - the deepest existing ancestor of the target realpaths inside realpath(root); containment is10 * compared with a trailing separator so `/ws/proj-evil` is never "inside" `/ws/proj`;11 * - a dangling symlink is never created-through, and writes never go through a symlink that12 * leaves the root; sockets, devices and FIFOs are refused.13 *14 * Residual risk: checks and use are not atomic (classic TOCTOU). The runtime is single-admin15 * and the roots are the admin's own workspaces, so this is accepted and documented.16 */17import { promises as fsp } from 'node:fs';18import type { Stats } from 'node:fs';19import path from 'node:path';20import type { RuntimeConfig } from './config.js';21import { RtError } from './plugins.js';2223export const MAX_REL_LENGTH = 4096;24export const MAX_SEGMENT_BYTES = 255;2526export interface ResolveOptions {27 /** The caller intends to create/modify the target: symlinks leaving the root are refused. */28 forWrite?: boolean;29 /**30 * Do not follow the final component (lstat semantics): used by delete / rename / stat so the31 * operation applies to a symlink itself, never to its target.32 */33 noFollowFinal?: boolean;34}3536export interface ResolvedPath {37 /** Absolute path to operate on (symlinks resolved, except the final one with noFollowFinal). */38 abs: string;39 /** realpath(root). */40 rootReal: string;41 /** Project-relative POSIX path of `abs` ("" = root). */42 rel: string;43 /** Whether the target exists (lstat when noFollowFinal, else stat). */44 exists: boolean;45 /** Stats of the target when it exists. */46 stats: Stats | null;47}4849/** Details-carrying error; handlers that need `details` in the body send the reply themselves. */50export class RtDetailError extends RtError {51 constructor(52 status: number,53 code: string,54 message: string,55 public readonly details: unknown,56 ) {57 super(status, code, message);58 }59}6061function isErrno(e: unknown, ...codes: string[]): boolean {62 const code = (e as NodeJS.ErrnoException | null)?.code;63 return typeof code === 'string' && codes.includes(code);64}6566/** True when `child` equals `parent` or lies strictly below it (both absolute, normalised). */67export function isInside(parent: string, child: string): boolean {68 if (child === parent) return true;69 const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep;70 return child.startsWith(prefix);71}7273/** Absolute path below `rootReal` → project-relative POSIX path ("" for the root itself). */74export function toRel(rootReal: string, abs: string): string {75 if (abs === rootReal) return '';76 if (!isInside(rootReal, abs)) throw new RtError(403, 'path_outside_root', 'path is outside the project root');77 return abs.slice(rootReal.length).split(path.sep).filter(Boolean).join('/');78}7980/** Validate the untrusted relative path syntactically and return its clean segments. */81export function splitRel(rel: unknown): string[] {82 if (typeof rel !== 'string') throw new RtError(400, 'bad_path', 'path must be a string');83 if (rel.includes('\0')) throw new RtError(400, 'bad_path', 'path contains a NUL byte');84 if (rel.length > MAX_REL_LENGTH) throw new RtError(400, 'bad_path', 'path is too long');85 if (rel.startsWith('/') || rel.startsWith('\\') || path.isAbsolute(rel) || path.win32.isAbsolute(rel)) {86 throw new RtError(400, 'bad_path', 'path must be relative');87 }88 // `..` is refused on BOTH separators and after compatibility normalisation, even though only89 // "/" is a separator on this platform: defence in depth against decoders further up the stack.90 for (const seg of rel.split(/[/\\]/)) {91 if (seg === '..' || seg.normalize('NFKC') === '..') {92 throw new RtError(400, 'bad_path', 'path must not contain ".." segments');93 }94 }95 const segments = rel.split('/').filter((s) => s !== '' && s !== '.');96 for (const seg of segments) {97 if (Buffer.byteLength(seg, 'utf8') > MAX_SEGMENT_BYTES) throw new RtError(400, 'bad_path', 'path segment is too long');98 }99 return segments;100}101102/**103 * `root` must be an absolute path whose realpath is a directory inside (or equal to) one of the104 * realpath'd configured workspace roots. Returns realpath(root).105 */106export async function assertRootAllowed(config: RuntimeConfig, root: unknown): Promise<string> {107 if (typeof root !== 'string' || root.length === 0 || root.includes('\0') || root.length > MAX_REL_LENGTH) {108 throw new RtError(400, 'bad_root', 'root must be a non-empty path');109 }110 if (!path.isAbsolute(root)) throw new RtError(400, 'bad_root', 'root must be absolute');111 let rootReal: string;112 try {113 rootReal = await fsp.realpath(root);114 } catch (e) {115 if (isErrno(e, 'ENOENT', 'ENOTDIR')) throw new RtError(404, 'root_not_found', 'root does not exist');116 throw new RtError(403, 'root_not_allowed', 'root is not accessible');117 }118 let allowed = false;119 for (const ws of config.workspaceRoots) {120 let wsReal: string;121 try {122 wsReal = await fsp.realpath(ws.path);123 } catch {124 continue; // a configured root that does not exist grants nothing125 }126 if (isInside(wsReal, rootReal)) {127 allowed = true;128 break;129 }130 }131 if (!allowed) throw new RtError(403, 'root_not_allowed', 'root is not inside a configured workspace root');132 const st = await fsp.stat(rootReal);133 if (!st.isDirectory()) throw new RtError(400, 'bad_root', 'root is not a directory');134 return rootReal;135}136137function assertNotSpecial(st: Stats): void {138 if (st.isSocket() || st.isFIFO() || st.isBlockDevice() || st.isCharacterDevice()) {139 throw new RtError(400, 'special_file', 'sockets, devices and FIFOs are not accessible');140 }141}142143async function lstatOrNull(p: string): Promise<Stats | null> {144 try {145 return await fsp.lstat(p);146 } catch (e) {147 if (isErrno(e, 'ENOENT', 'ENOTDIR')) return null;148 throw e;149 }150}151152/** realpath that returns null when the path (or a link target) does not exist. */153async function realpathOrNull(p: string): Promise<string | null> {154 try {155 return await fsp.realpath(p);156 } catch (e) {157 if (isErrno(e, 'ENOENT', 'ENOTDIR')) return null;158 if (isErrno(e, 'ELOOP')) throw new RtError(403, 'path_outside_root', 'symlink loop');159 if (isErrno(e, 'EACCES', 'EPERM')) throw new RtError(403, 'forbidden', 'path is not accessible');160 if (isErrno(e, 'ENAMETOOLONG')) throw new RtError(400, 'bad_path', 'path is too long');161 throw e;162 }163}164165/** Full resolution result; see `resolveInside` for the string-returning convenience form. */166export async function resolveInsideEx(167 config: RuntimeConfig,168 root: unknown,169 rel: unknown,170 opts: ResolveOptions = {},171): Promise<ResolvedPath> {172 const segments = splitRel(rel);173 const rootReal = await assertRootAllowed(config, root);174 const outside = (): RtError => new RtError(403, 'path_outside_root', 'path resolves outside the project root');175176 if (segments.length === 0) {177 const st = await fsp.stat(rootReal);178 return { abs: rootReal, rootReal, rel: '', exists: true, stats: st };179 }180181 // With noFollowFinal only the parent chain is resolved; the last name is kept verbatim.182 const walk = opts.noFollowFinal ? segments.slice(0, -1) : segments;183 const finalName = opts.noFollowFinal ? (segments[segments.length - 1] as string) : null;184185 // Deepest existing ancestor (realpath fails on missing entries and on dangling symlinks).186 let existing = walk.length;187 let real: string | null = null;188 for (; existing >= 0; existing--) {189 real = await realpathOrNull(path.join(rootReal, ...walk.slice(0, existing)));190 if (real !== null) break;191 }192 if (real === null) throw new RtError(404, 'root_not_found', 'root does not exist');193 if (!isInside(rootReal, real)) throw outside();194195 const missing = walk.slice(existing);196 if (missing.length > 0) {197 // The first missing component must be truly absent: if lstat finds something, it is a198 // dangling symlink (or a non-directory), and creating "through" it could leave the root.199 const st = await lstatOrNull(path.join(real, missing[0] as string));200 if (st?.isSymbolicLink()) throw new RtError(403, 'path_outside_root', 'path goes through a dangling symlink');201 const parentSt = await fsp.stat(real);202 if (!parentSt.isDirectory()) throw new RtError(400, 'not_a_directory', 'a parent of the path is not a directory');203 }204205 let abs = missing.length > 0 ? path.join(real, ...missing) : real;206 let stats: Stats | null = null;207 if (finalName !== null) {208 abs = path.join(abs, finalName);209 stats = missing.length > 0 ? null : await lstatOrNull(abs);210 if (stats?.isSymbolicLink() && opts.forWrite) {211 // Destination of a write-like operation is itself a symlink: only tolerated when its212 // target stays inside the root (the caller still operates on the link, not the target).213 const target = await realpathOrNull(abs);214 if (target === null || !isInside(rootReal, target)) throw outside();215 }216 } else if (missing.length === 0) {217 stats = await fsp.stat(abs);218 }219 if (!isInside(rootReal, abs)) throw outside();220 if (stats) assertNotSpecial(stats);221 return { abs, rootReal, rel: toRel(rootReal, abs), exists: stats !== null, stats };222}223224/**225 * Resolve the untrusted `rel` below `root` and return the absolute path to operate on.226 * Throws RtError 400 (`bad_path`, `bad_root`, `special_file`), 403 (`root_not_allowed`,227 * `path_outside_root`) or 404 (`root_not_found`).228 */229export async function resolveInside(230 config: RuntimeConfig,231 root: unknown,232 rel: unknown,233 opts: ResolveOptions = {},234): Promise<string> {235 return (await resolveInsideEx(config, root, rel, opts)).abs;236}237