UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

162 lines 5.95 kB
/** * Local path validation for dashboard browsing, terminal launch, state writes, and uploads. * * These guards keep browser-supplied paths inside the selected project or the allowed goat-flow * state area before server routes touch the filesystem. */ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; /** Structured validation failure returned to dashboard callers as a safe rejection. */ class LocalPathValidationError extends Error { validationClass; purpose; constructor(purpose, validationClass) { super(`Local path validation failed (${purpose}): ${validationClass.replace(/-/gu, " ")}`); this.name = "LocalPathValidationError"; this.validationClass = validationClass; this.purpose = purpose; } } export { LocalPathValidationError }; const EXACT_BLOCKED_POSIX_ROOTS = new Set([ "/", "/bin", "/sbin", "/usr/bin", "/usr/sbin", "/etc", "/var", "/tmp", "/dev", "/proc", "/sys", "/root", "/boot", "/lib", "/lib64", "/private/etc", "/private/var", "/private/tmp", ]); const DESCENDANT_BLOCKED_POSIX_ROOTS = [ "/bin", "/sbin", "/usr/bin", "/usr/sbin", "/etc", "/dev", "/proc", "/sys", "/root", "/boot", "/lib", "/lib64", "/private/etc", ]; /** Normalize candidate paths to POSIX shape before comparing against policy roots. */ function toPosixPath(path) { const normalized = path.replace(/\\/gu, "/").replace(/\/+/gu, "/"); return normalized.length > 1 ? normalized.replace(/\/$/u, "") : normalized; } // Containment guard used before filesystem access: true when child resolves // inside parent (or equals it). Pure path arithmetic — it does NOT resolve // symlinks, so callers needing real-path safety must canonicalise first. export function isPathWithin(parent, child) { const rel = relative(parent, child); if (rel === "") return true; if (isAbsolute(rel)) return false; const [firstSegment] = rel.split(/[\\/]/u); return firstSegment !== ".."; } /** Exempt browse-only requests from terminal/write local-path restrictions. */ function isPolicyEnforcedPurpose(purpose) { return purpose !== "browse"; } function blockedClassForPath(path, purpose) { if (!isPolicyEnforcedPurpose(purpose)) return null; const posixPath = toPosixPath(path); if (EXACT_BLOCKED_POSIX_ROOTS.has(posixPath)) return "blocked-root"; if (DESCENDANT_BLOCKED_POSIX_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`))) { return "blocked-descendant"; } return null; } function assertAllowedByPurpose(resolvedPath, realPath, purpose) { const resolvedBlock = blockedClassForPath(resolvedPath, purpose); if (resolvedBlock) throw new LocalPathValidationError(purpose, resolvedBlock); const realBlock = blockedClassForPath(realPath, purpose); if (realBlock) throw new LocalPathValidationError(purpose, realBlock); } export function validateLocalPath(rawPath, purpose) { const resolvedPath = resolve(rawPath); let stats; try { stats = statSync(resolvedPath); } catch { throw new LocalPathValidationError(purpose, "missing"); } if (!stats.isDirectory()) { throw new LocalPathValidationError(purpose, "not-directory"); } const realPath = realpathSync(resolvedPath); assertAllowedByPurpose(resolvedPath, realPath, purpose); return { path: resolvedPath, realPath, purpose }; } /** Return existing path components so symlink checks only touch filesystem entries that exist. */ function existingPathComponents(from, target) { const rel = relative(from, target); if (rel === "") return [from]; if (isAbsolute(rel) || rel.startsWith("..")) return []; const components = rel.split(/[\\/]/u).filter(Boolean); const paths = [from]; let current = from; for (const component of components) { current = join(current, component); paths.push(current); } return paths.filter((path) => existsSync(path)); } function assertExistingComponentsStayInside(realRoot, components) { for (const [index, component] of components.entries()) { if (index > 0 && lstatSync(component).isSymbolicLink()) { throw new LocalPathValidationError("state-path", "state-path-escape"); } if (!isPathWithin(realRoot, realpathSync(component))) { throw new LocalPathValidationError("state-path", "state-path-escape"); } } } function assertLocalStatePathPurpose(project) { if (project.purpose !== "write-local-state" && project.purpose !== "upload") { throw new LocalPathValidationError("state-path", "state-path-escape"); } } export function resolveValidatedLocalStatePath(project, relativePath) { assertLocalStatePathPurpose(project); const stateRoot = resolve(project.path, ".goat-flow"); const candidate = resolve(stateRoot, relativePath); if (!isPathWithin(stateRoot, candidate)) { throw new LocalPathValidationError("state-path", "state-path-escape"); } assertExistingComponentsStayInside(project.realPath, existingPathComponents(project.path, candidate)); return candidate; } export function resolveLocalStatePath(projectPath, relativePath, purpose = "write-local-state") { return resolveValidatedLocalStatePath(validateLocalPath(projectPath, purpose), relativePath); } // Security gate for terminal working directories: throws (via validateLocalPath) // unless projectPath clears the terminal-cwd policy, otherwise returns the // normalised absolute path safe to hand to a spawned shell. export function validateProjectPath(projectPath) { return validateLocalPath(projectPath, "terminal-cwd").path; } //# sourceMappingURL=local-paths.js.map