/** * KHAELOR * File: src/permissions/paths.ts * Description: Pure path helpers for capability classification — resolution, project-root scoping, home expansion. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import * as path from "node:path"; /** * Context every path-classifying function receives. The project root is * `workspace.cwd()` resolved (and symlink-canonicalized) at session start * (PERMISSION_MODEL.md §1). `resolvePath` is an injected canonicalizer * (realpath-style) supplied by the composition root — this module never * touches the filesystem itself (ARCHITECTURE.md §2.1: workspace is the * only fs module). */ export interface PathContext { /** Absolute, canonical project root. */ projectRoot: string; /** Effective cwd for resolving relative paths (usually the project root). */ cwd: string; /** Optional symlink canonicalizer applied AFTER lexical resolution. */ resolvePath?: (absolutePath: string) => string; /** Home directory for `~` / `$HOME` expansion. */ home?: string; } /** Expand a leading `~` or `$HOME` in a single shell word. */ export function expandHomeWord(word: string, home: string): string { if (home.length === 0) return word; if (word === "~" || word === "$HOME") return home; if (word.startsWith("~/")) return home + word.slice(1); if (word.startsWith("$HOME/")) return home + word.slice(5); return word; } /** * Fully resolve a path subject: home expansion, relative resolution against * the effective cwd, lexical `..` normalization, then the injected symlink * canonicalizer. Subjects are resolved BEFORE classification — a write to * `./x/../../etc/hosts` is outside the project (PERMISSION_MODEL.md §1). */ export function resolveSubjectPath(raw: string, ctx: PathContext): string { const expanded = ctx.home !== undefined ? expandHomeWord(raw, ctx.home) : raw; const absolute = path.resolve(ctx.cwd, expanded); return ctx.resolvePath ? ctx.resolvePath(absolute) : absolute; } /** True when `resolved` is the project root or strictly under it. */ export function isUnderRoot(resolved: string, root: string): boolean { const normalizedRoot = path.normalize(root); if (resolved === normalizedRoot) return true; const prefix = normalizedRoot.endsWith(path.sep) ? normalizedRoot : normalizedRoot + path.sep; return resolved.startsWith(prefix); } /** Collapse runs of whitespace — command subjects are matched collapsed (PERMISSION_MODEL.md §4.1). */ export function collapseWhitespace(text: string): string { return text.trim().replace(/\s+/g, " "); }