@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
JavaScript
/**
* 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