Skip to main content
Glama
path-utils.ts12.6 kB
import { join, normalize, sep, resolve, posix } from 'path'; import { access, stat, mkdir } from 'fs/promises'; import { constants } from 'fs'; export class PathUtils { /** macOS and Windows are case-insensitive filesystems */ private static readonly IS_CASE_INSENSITIVE = process.platform === 'darwin' || process.platform === 'win32'; /** Cached path configuration (undefined = not checked, null = invalid/missing) */ private static pathConfig: { hostPrefix: string; containerPrefix: string } | null | undefined; /** * Get cached path configuration from environment variables. * Caches result to prevent race conditions from env var changes mid-operation. */ private static getPathConfig(): { hostPrefix: string; containerPrefix: string } | null { if (this.pathConfig !== undefined) { return this.pathConfig; } const hostPrefix = process.env.SPEC_WORKFLOW_HOST_PATH_PREFIX?.trim(); const containerPrefix = process.env.SPEC_WORKFLOW_CONTAINER_PATH_PREFIX?.trim(); if (!hostPrefix || !containerPrefix) { this.pathConfig = null; return null; } // Validate absolute paths if (!this.isAbsolutePath(hostPrefix) || !this.isAbsolutePath(containerPrefix)) { console.error('[PathUtils] Path prefixes must be absolute paths'); this.pathConfig = null; return null; } // Security: Reject prefixes containing directory traversal if (hostPrefix.includes('..') || containerPrefix.includes('..')) { console.error('[PathUtils] Path prefixes must not contain directory traversal (..)'); this.pathConfig = null; return null; } this.pathConfig = { hostPrefix, containerPrefix }; return this.pathConfig; } /** Check if path is absolute (Unix or Windows style) */ private static isAbsolutePath(path: string): boolean { return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path); } /** Reset cached config (for testing) */ static resetPathConfig(): void { this.pathConfig = undefined; } /** * Normalize path for cross-platform comparison using built-in path.posix. * Converts backslashes to forward slashes, removes trailing slashes. */ private static normalizeForComparison(p: string): string { // Convert to Unix-style, then use built-in posix.normalize const unixStyle = p.replace(/\\/g, '/'); // posix.normalize handles /./, /../, and // but keeps trailing slash const normalized = posix.normalize(unixStyle); // Remove trailing slash for consistent comparison (except for root "/") return normalized.endsWith('/') && normalized.length > 1 ? normalized.slice(0, -1) : normalized; } /** * Check if a path matches a prefix with proper boundary checking. * - Prevents partial matches like "/Users/dev" matching "/Users/developer" * - Handles case-insensitivity on macOS/Windows * - Normalizes path separators for cross-platform support */ private static pathMatchesPrefix(path: string, prefix: string): boolean { let normalizedPath = this.normalizeForComparison(path); let normalizedPrefix = this.normalizeForComparison(prefix); if (this.IS_CASE_INSENSITIVE) { normalizedPath = normalizedPath.toLowerCase(); normalizedPrefix = normalizedPrefix.toLowerCase(); } if (normalizedPath === normalizedPrefix) return true; // Special case: root prefix "/" matches any absolute path if (normalizedPrefix === '/') { return normalizedPath.startsWith('/'); } return normalizedPath.startsWith(normalizedPrefix + '/'); } /** * Translate a host path to container path if running in Docker with path mapping configured. * * Environment variables: * - SPEC_WORKFLOW_HOST_PATH_PREFIX: Path prefix on the host (e.g., /Users/username) * - SPEC_WORKFLOW_CONTAINER_PATH_PREFIX: Corresponding path in container (e.g., /projects) * * Example: If host prefix is "/Users/dev" and container prefix is "/projects", * then "/Users/dev/myapp" becomes "/projects/myapp" */ static translatePath(hostPath: string): string { const config = this.getPathConfig(); if (!config) return hostPath; if (this.pathMatchesPrefix(hostPath, config.hostPrefix)) { const normalizedHostPrefix = this.normalizeForComparison(config.hostPrefix); const normalizedPath = this.normalizeForComparison(hostPath); const normalizedContainerPrefix = this.normalizeForComparison(config.containerPrefix); // Get relative path preserving structure let relativePath = normalizedPath.substring(normalizedHostPrefix.length); // Ensure relative path starts with separator (needed for root prefix case) if (relativePath && !relativePath.startsWith('/')) { relativePath = '/' + relativePath; } const result = normalizedContainerPrefix + relativePath; // Security: Validate no directory traversal in result if (result.includes('/../') || result.endsWith('/..')) { throw new Error('Path translation resulted in directory traversal attempt'); } return result; } return hostPath; } /** * Reverse translation: container path back to host path (for display/registry) */ static reverseTranslatePath(containerPath: string): string { const config = this.getPathConfig(); if (!config) return containerPath; if (this.pathMatchesPrefix(containerPath, config.containerPrefix)) { const normalizedContainerPrefix = this.normalizeForComparison(config.containerPrefix); const normalizedPath = this.normalizeForComparison(containerPath); const normalizedHostPrefix = this.normalizeForComparison(config.hostPrefix); // Get relative path preserving structure let relativePath = normalizedPath.substring(normalizedContainerPrefix.length); // Ensure relative path starts with separator (needed for root prefix case) if (relativePath && !relativePath.startsWith('/')) { relativePath = '/' + relativePath; } const result = normalizedHostPrefix + relativePath; // Security: Validate no directory traversal in result if (result.includes('/../') || result.endsWith('/..')) { throw new Error('Path translation resulted in directory traversal attempt'); } return result; } return containerPath; } /** * Safely join paths ensuring no directory traversal */ private static safeJoin(basePath: string, ...paths: string[]): string { // Validate base path if (!basePath || typeof basePath !== 'string') { throw new Error('Invalid base path'); } // Check each path segment for traversal attempts for (const pathSegment of paths) { if (pathSegment && (pathSegment.includes('..') || pathSegment.startsWith('/'))) { throw new Error(`Invalid path segment: ${pathSegment}`); } } const joined = normalize(join(basePath, ...paths)); const resolvedBase = resolve(basePath); const resolvedJoined = resolve(joined); // Ensure the joined path is within the base path if (!resolvedJoined.startsWith(resolvedBase)) { throw new Error('Path traversal detected in join operation'); } return joined; } static getWorkflowRoot(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow'); } static getSpecPath(projectPath: string, specName: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'specs', specName); } static getArchiveSpecPath(projectPath: string, specName: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'archive', 'specs', specName); } static getArchiveSpecsPath(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'archive', 'specs'); } static getSteeringPath(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'steering'); } static getTemplatesPath(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'templates'); } static getAgentsPath(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'agents'); } static getCommandsPath(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'commands'); } static getApprovalsPath(projectPath: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'approvals'); } static getSpecApprovalPath(projectPath: string, specName: string): string { return this.safeJoin(projectPath, '.spec-workflow', 'approvals', specName); } // Ensure paths work across Windows, macOS, Linux static toPlatformPath(path: string): string { return path.split('/').join(sep); } static toUnixPath(path: string): string { return path.split(sep).join('/'); } // Get relative path from project root static getRelativePath(projectPath: string, fullPath: string): string { const normalizedProject = normalize(projectPath); const normalizedFull = normalize(fullPath); if (normalizedFull.startsWith(normalizedProject)) { return normalizedFull.slice(normalizedProject.length + 1); } return normalizedFull; } } export async function validateProjectPath(projectPath: string): Promise<string> { try { // Validate input if (!projectPath || typeof projectPath !== 'string') { throw new Error('Invalid project path: path must be a non-empty string'); } // Check for dangerous path patterns before resolving if (projectPath.includes('..') || projectPath.includes('~')) { // Normalize the path first to check if it's actually traversing const normalized = normalize(projectPath); const resolved = resolve(normalized); // Get the current working directory for comparison const cwd = process.cwd(); // Additional check: ensure the resolved path doesn't contain parent directory references if (normalized.includes('..') && !resolved.startsWith(cwd)) { throw new Error(`Path traversal detected: ${projectPath}`); } } // Resolve to absolute path const absolutePath = resolve(projectPath); // Security check: Ensure the path doesn't escape to system directories const systemPaths = ['/etc', '/usr', '/bin', '/sbin', '/var', '/sys', '/proc']; const windowsSystemPaths = ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)']; const allSystemPaths = process.platform === 'win32' ? windowsSystemPaths : systemPaths; for (const sysPath of allSystemPaths) { if (absolutePath.toLowerCase().startsWith(sysPath.toLowerCase())) { throw new Error(`Access to system directory not allowed: ${absolutePath}`); } } // Check if path exists await access(absolutePath, constants.F_OK); // Ensure it's a directory const stats = await stat(absolutePath); if (!stats.isDirectory()) { throw new Error(`Project path is not a directory: ${absolutePath}`); } // Final security check: ensure we can actually access this directory await access(absolutePath, constants.R_OK | constants.W_OK); return absolutePath; } catch (error) { if (error instanceof Error) { if ((error as any).code === 'ENOENT') { throw new Error(`Project path does not exist: ${projectPath}`); } else if ((error as any).code === 'EACCES') { throw new Error(`Permission denied accessing project path: ${projectPath}`); } throw error; } throw new Error(`Unknown error validating project path: ${String(error)}`); } } export async function ensureDirectoryExists(dirPath: string): Promise<void> { try { await access(dirPath, constants.F_OK); } catch { await mkdir(dirPath, { recursive: true }); } } export async function ensureWorkflowDirectory(projectPath: string): Promise<string> { const workflowRoot = PathUtils.getWorkflowRoot(projectPath); // Create all necessary subdirectories (approvals created on-demand) const directories = [ workflowRoot, PathUtils.getSpecPath(projectPath, ''), PathUtils.getArchiveSpecsPath(projectPath), PathUtils.getSteeringPath(projectPath), PathUtils.getTemplatesPath(projectPath), PathUtils.getAgentsPath(projectPath), PathUtils.getCommandsPath(projectPath) ]; for (const dir of directories) { await ensureDirectoryExists(dir); } return workflowRoot; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Pimzino/spec-workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server