| import { createHash } from 'node:crypto'; |
| import { |
| lstat, |
| mkdir, |
| readFile, |
| readlink, |
| realpath, |
| rename, |
| unlink, |
| writeFile, |
| } from 'node:fs/promises'; |
| import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; |
| |
| /** |
| * Shared filesystem-containment and identifier guards. This is the single |
| * authority for path-containment checks across the runtime, the desktop main |
| * process, and headless: both the pure-Node runtime and the desktop main (which |
| * already depends on `@maka/runtime`) reach it here without reverse |
| * dependencies. {@link isPathInside} itself is a pure `node:path` predicate; the |
| * canonicalisation and contained-I/O helpers around it touch the filesystem. |
| * |
| * {@link isPathInside} is separator-aware: it rejects only a real |
| * parent-reference segment (`..` exactly, or `..${sep}`-prefixed), so a child |
| * entry whose own name begins with `..` (e.g. `root/..foo`) is correctly |
| * treated as inside. Its `pathApi` parameter makes the Windows cross-drive case |
| * and POSIX sandbox paths testable. The bare-`startsWith('..')` variant that |
| * preceded it was retired in #1145 because it misclassified such names as |
| * escapes; identifier safety is handled separately by {@link isSafeSkillId}. |
| */ |
| |
| /** |
| * True when `target` is inside (or equal to) `root`. Used by the skill reader, |
| * the managed skill-source store, the filesystem worker, and the workspace |
| * executor to keep resolved paths inside their approved root. |
| */ |
| export function isPathInside( |
| root: string, |
| target: string, |
| pathApi: PathInsideApi = { relative, isAbsolute, sep }, |
| ): boolean { |
| const rel = pathApi.relative(root, target); |
| // path.relative returns the target path unchanged (absolute) when root and |
| // target are on different drives on Windows. An absolute result means the |
| // target is not reachable from root via a relative path, so reject it before |
| // the `..` escape check. |
| if (pathApi.isAbsolute(rel)) return false; |
| // Reject only a real parent-reference segment: the exact ".." or a path |
| // starting with `..${sep}`. A leading ".." followed by anything else (e.g. |
| // "..rules") is a legitimate directory name, not an escape. |
| return rel === '' || (rel !== '..' && !rel.startsWith(`..${pathApi.sep}`)); |
| } |
| |
| /** Path primitives {@link isPathInside} uses, injectable for cross-platform tests. */ |
| export interface PathInsideApi { |
| relative: typeof relative; |
| isAbsolute: typeof isAbsolute; |
| sep: string; |
| } |
| |
| /** True when `value` is a safe skill/source identifier (no path or control chars). */ |
| export function isSafeSkillId(value: string): boolean { |
| return /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(value); |
| } |
| |
| /** Relative POSIX path from `root` to `target`, or `.` when they are equal. */ |
| export function toRelative(root: string, target: string): string { |
| const rel = relative(root, target); |
| return rel === '' ? '.' : rel.split(sep).join('/'); |
| } |
| |
| /** |
| * Canonicalise `target` even when its leaf (or a run of trailing segments) does |
| * not exist yet: the deepest existing ancestor is realpath'd and the missing |
| * segments are appended. Containment can then be decided against a realpath'd |
| * root in a single path space, which is what {@link isPathInside} assumes — |
| * comparing a realpath'd root against a merely `resolve`d candidate rejects |
| * every legitimate absolute path whenever the root sits under a symlink (macOS |
| * `/var` → `/private/var`, symlinked workspace roots). |
| * |
| * Because symlinks are followed all the way to the leaf, this never legalises |
| * an escape: a link inside the root that points out of it resolves to its |
| * outside target and fails containment. That includes a dangling link, whose |
| * `realpath` fails ENOENT — the link is read and followed by hand, because a |
| * write through it lands on its target, not on the link. |
| * |
| * Throws the underlying error when a path component is unreadable rather than |
| * missing, so permission problems are not silently treated as containment, and |
| * when a symlink cycle keeps the walk from terminating. |
| */ |
| export async function realpathAllowMissing(target: string): Promise<string> { |
| let cursor = resolve(target); |
| const missing: string[] = []; |
| // `realpath` reports a cycle as ELOOP, so each hop consumes one existing link |
| // and the walk terminates; the cap is a backstop against a pathological |
| // filesystem, not the cycle guard. |
| let hops = 0; |
| while (true) { |
| try { |
| const realExisting = await realpath(cursor); |
| return resolve(realExisting, ...missing.reverse()); |
| } catch (error) { |
| if (!isMissingPathError(error)) throw error; |
| const link = await readlink(cursor).catch(() => null); |
| if (link !== null) { |
| if (++hops > MAX_DANGLING_SYMLINK_HOPS) |
| throw new Error(`Path ${JSON.stringify(target)} traverses too many dangling symlinks.`); |
| cursor = resolve(dirname(cursor), link); |
| continue; |
| } |
| const parent = dirname(cursor); |
| if (parent === cursor) throw error; |
| missing.push(basename(cursor)); |
| cursor = parent; |
| } |
| } |
| } |
| |
| /** Dangling links followed by {@link realpathAllowMissing} before it gives up. */ |
| const MAX_DANGLING_SYMLINK_HOPS = 32; |
| |
| function isMissingPathError(error: unknown): boolean { |
| return ( |
| typeof error === 'object' && |
| error !== null && |
| 'code' in error && |
| (error.code === 'ENOENT' || error.code === 'ENOTDIR') |
| ); |
| } |
| |
| // ── Contained file I/O ──────────────────────────────────────────────────── |
| |
| /** |
| * Read a regular file, verifying its realpath stays inside `rootDir`. |
| * Rejects symlinks and paths that escape the containment root. |
| */ |
| export async function readContainedRegularFile( |
| rootDir: string, |
| filePath: string, |
| ): Promise<{ ok: true; bytes: Buffer } | { ok: false }> { |
| try { |
| const [rootReal, fileStat] = await Promise.all([realpath(rootDir), lstat(filePath)]); |
| if (!fileStat.isFile() || fileStat.isSymbolicLink()) return { ok: false }; |
| const fileReal = await realpath(filePath); |
| if (!isPathInside(rootReal, fileReal)) return { ok: false }; |
| return { ok: true, bytes: await readFile(filePath) }; |
| } catch { |
| return { ok: false }; |
| } |
| } |
| |
| /** |
| * Read a regular text file, verifying its realpath stays inside `rootDir`. |
| * Returns the content and its sha256 hash. |
| */ |
| export async function readContainedRegularTextFile( |
| rootDir: string, |
| filePath: string, |
| ): Promise< |
| | { ok: true; content: string; sha256: string } |
| | { ok: false; reason: 'blocked_path' | 'read_failed' } |
| > { |
| try { |
| const [rootReal, fileStat] = await Promise.all([realpath(rootDir), lstat(filePath)]); |
| if (!fileStat.isFile() || fileStat.isSymbolicLink()) |
| return { ok: false, reason: 'blocked_path' }; |
| const fileReal = await realpath(filePath); |
| if (!isPathInside(rootReal, fileReal)) return { ok: false, reason: 'blocked_path' }; |
| const content = await readFile(filePath, 'utf8'); |
| return { ok: true, content, sha256: `sha256:${sha256(content)}` }; |
| } catch { |
| return { ok: false, reason: 'read_failed' }; |
| } |
| } |
| |
| /** |
| * Atomically write a text file inside `rootDir` via a temp file + rename. |
| * Rejects symlinks and paths that escape the containment root. |
| */ |
| export async function writeContainedRegularTextFile( |
| rootDir: string, |
| filePath: string, |
| content: string, |
| ): Promise<boolean> { |
| const tempPath = join(rootDir, `.maka-write.${process.pid}.${Date.now()}.tmp`); |
| try { |
| const rootReal = await realpath(rootDir); |
| const existing = await lstat(filePath).catch((error: NodeJS.ErrnoException) => { |
| if (error.code === 'ENOENT') return null; |
| throw error; |
| }); |
| if (existing !== null && (!existing.isFile() || existing.isSymbolicLink())) return false; |
| if (existing !== null) { |
| const fileReal = await realpath(filePath); |
| if (!isPathInside(rootReal, fileReal)) return false; |
| } |
| await writeFile(tempPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); |
| const tempStat = await lstat(tempPath); |
| if (!tempStat.isFile() || tempStat.isSymbolicLink()) { |
| await unlink(tempPath).catch(() => {}); |
| return false; |
| } |
| const tempReal = await realpath(tempPath); |
| if (!isPathInside(rootReal, tempReal)) { |
| await unlink(tempPath).catch(() => {}); |
| return false; |
| } |
| await rename(tempPath, filePath); |
| return true; |
| } catch { |
| await unlink(tempPath).catch(() => {}); |
| return false; |
| } |
| } |
| |
| // ── Generic type guard ──────────────────────────────────────────────────── |
| |
| /** True when `value` is a plain record (object, not array, not null). */ |
| export function isRecord(value: unknown): value is Record<string, unknown> { |
| return typeof value === 'object' && value !== null && !Array.isArray(value); |
| } |
| |
| // ── Crypto helper ───────────────────────────────────────────────────────── |
| |
| function sha256(text: string): string { |
| return createHash('sha256').update(text).digest('hex'); |
| } |