UNPKG

@sirmrmarty/n8n-nodes-tmux-orchestrator

Version:

n8n nodes for orchestrating Claude AI agents through tmux sessions

442 lines (368 loc) 12.6 kB
/** * Command Sanitization Utility * Provides secure command execution with input validation and sanitization */ export interface SanitizedCommand { command: string; args: string[]; isValid: boolean; errors: string[]; } export class CommandSanitizer { // Allowed tmux commands - whitelist approach private static readonly ALLOWED_TMUX_COMMANDS = new Set([ 'new-session', 'new-window', 'send-keys', 'capture-pane', 'list-sessions', 'list-windows', 'kill-session', 'kill-window', 'has-session', 'display-message' ]); // Dangerous patterns that should never appear in commands private static readonly DANGEROUS_PATTERNS = [ /[;&|`$(){}]/, // Shell metacharacters /\.\./, // Directory traversal /[<>]/, // Redirections /\s*rm\s/i, // Remove commands /\s*sudo\s/i, // Privilege escalation /\s*chmod\s/i, // Permission changes /\s*wget\s/i, // Downloads /\s*curl\s/i, // Downloads /\s*ssh\s/i, // Remote access /\s*nc\s/i, // Network connections /\\\\/, // Escape sequences ]; // Safe characters for project names and session identifiers private static readonly SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z0-9_-]+$/; /** * Sanitize a tmux command and its arguments */ public static sanitizeTmuxCommand(command: string, args: string[]): SanitizedCommand { const result: SanitizedCommand = { command, args: [], isValid: true, errors: [] }; // Validate command is in whitelist if (!this.ALLOWED_TMUX_COMMANDS.has(command)) { result.isValid = false; result.errors.push(`Command '${command}' is not in the allowed list`); return result; } // Sanitize each argument for (const arg of args) { const sanitizedArg = this.sanitizeArgument(arg); if (sanitizedArg.isValid) { result.args.push(sanitizedArg.value); } else { result.isValid = false; result.errors.push(...sanitizedArg.errors); } } return result; } /** * Sanitize a single command argument */ private static sanitizeArgument(arg: string): { value: string; isValid: boolean; errors: string[] } { const result = { value: arg, isValid: true, errors: [] as string[] }; // Check for null/undefined if (arg == null) { result.isValid = false; result.errors.push('Argument cannot be null or undefined'); return result; } // Convert to string and trim const stringArg = String(arg).trim(); // Check length if (stringArg.length === 0) { result.isValid = false; result.errors.push('Argument cannot be empty'); return result; } if (stringArg.length > 1000) { result.isValid = false; result.errors.push('Argument exceeds maximum length of 1000 characters'); return result; } // Check for dangerous patterns for (const pattern of this.DANGEROUS_PATTERNS) { if (pattern.test(stringArg)) { result.isValid = false; result.errors.push(`Argument contains dangerous pattern: ${pattern.source}`); } } result.value = stringArg; return result; } /** * Validate project name specifically */ public static validateProjectName(projectName: string): { isValid: boolean; errors: string[] } { const result = { isValid: true, errors: [] as string[] }; if (!projectName || typeof projectName !== 'string') { result.isValid = false; result.errors.push('Project name must be a non-empty string'); return result; } const trimmed = projectName.trim(); if (trimmed.length === 0) { result.isValid = false; result.errors.push('Project name cannot be empty'); } if (trimmed.length > 50) { result.isValid = false; result.errors.push('Project name cannot exceed 50 characters'); } if (!this.SAFE_IDENTIFIER_PATTERN.test(trimmed)) { result.isValid = false; result.errors.push('Project name can only contain letters, numbers, underscores, and hyphens'); } // Check for reserved names const reservedNames = ['system', 'admin', 'root', 'tmp', 'var', 'etc', 'bin']; if (reservedNames.includes(trimmed.toLowerCase())) { result.isValid = false; result.errors.push('Project name cannot be a reserved system name'); } return result; } /** * Validate session name */ public static validateSessionName(sessionName: string): { isValid: boolean; errors: string[] } { // Session names have similar requirements to project names return this.validateProjectName(sessionName); } /** * Sanitize file path to prevent directory traversal */ public static sanitizeFilePath(filePath: string, allowedBasePath?: string): { path: string; isValid: boolean; errors: string[] } { const result = { path: filePath, isValid: true, errors: [] as string[] }; if (!filePath || typeof filePath !== 'string') { result.isValid = false; result.errors.push('File path must be a non-empty string'); return result; } // Normalize the path const path = require('path'); let normalizedPath: string; try { normalizedPath = path.normalize(filePath); } catch (error) { result.isValid = false; result.errors.push('Invalid file path format'); return result; } // Check for directory traversal attempts if (normalizedPath.includes('..') || normalizedPath.includes('/.')) { result.isValid = false; result.errors.push('File path contains directory traversal attempts'); } // Check if path is within allowed base path if (allowedBasePath) { const resolvedPath = path.resolve(normalizedPath); const resolvedBase = path.resolve(allowedBasePath); if (!resolvedPath.startsWith(resolvedBase)) { result.isValid = false; result.errors.push('File path is outside of allowed directory'); } } // Check for dangerous paths const dangerousPaths = ['/etc', '/var', '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/root']; for (const dangerousPath of dangerousPaths) { if (normalizedPath.startsWith(dangerousPath)) { result.isValid = false; result.errors.push('File path accesses system directory'); } } result.path = normalizedPath; return result; } /** * Create a safe tmux target string */ public static createSafeTmuxTarget(sessionName: string, windowIndex: number | string): string { const sessionValidation = this.validateSessionName(sessionName); if (!sessionValidation.isValid) { throw new Error(`Invalid session name: ${sessionValidation.errors.join(', ')}`); } // Validate window index const windowIndexNum = typeof windowIndex === 'string' ? parseInt(windowIndex, 10) : windowIndex; if (isNaN(windowIndexNum) || windowIndexNum < 0 || windowIndexNum > 999) { throw new Error('Window index must be a number between 0 and 999'); } return `${sessionName}:${windowIndexNum}`; } /** * Validate and sanitize tmux command execution */ public static prepareTmuxExecution(command: string, args: string[]): SanitizedCommand { const sanitized = this.sanitizeTmuxCommand(command, args); if (!sanitized.isValid) { throw new Error(`Command sanitization failed: ${sanitized.errors.join(', ')}`); } return sanitized; } /** * Validate project idea input */ public static validateProjectIdea(projectIdea: string): { isValid: boolean; errors: string[] } { const result = { isValid: true, errors: [] as string[] }; if (!projectIdea || typeof projectIdea !== 'string') { result.isValid = false; result.errors.push('Project idea must be a non-empty string'); return result; } const trimmed = projectIdea.trim(); if (trimmed.length === 0) { result.isValid = false; result.errors.push('Project idea cannot be empty'); } if (trimmed.length < 10) { result.isValid = false; result.errors.push('Project idea must be at least 10 characters long'); } if (trimmed.length > 5000) { result.isValid = false; result.errors.push('Project idea cannot exceed 5000 characters'); } // Check for dangerous patterns in project ideas for (const pattern of this.DANGEROUS_PATTERNS) { if (pattern.test(trimmed)) { result.isValid = false; result.errors.push('Project idea contains potentially dangerous content'); } } return result; } /** * Validate quality requirements */ public static validateQualityRequirements(qualityRequirements: string): { isValid: boolean; errors: string[] } { const result = { isValid: true, errors: [] as string[] }; if (!qualityRequirements || typeof qualityRequirements !== 'string') { result.isValid = false; result.errors.push('Quality requirements must be a non-empty string'); return result; } const validQualityLevels = ['lite', 'standard', 'high', 'critical']; if (!validQualityLevels.includes(qualityRequirements.toLowerCase())) { result.isValid = false; result.errors.push(`Quality requirements must be one of: ${validQualityLevels.join(', ')}`); } return result; } /** * Validate time string format */ public static validateTimeString(timeString: string): { isValid: boolean; errors: string[] } { const result = { isValid: true, errors: [] as string[] }; if (!timeString || typeof timeString !== 'string') { // Time string can be optional for some operations return result; } const trimmed = timeString.trim(); // Allow common time formats: HH:MM, YYYY-MM-DD HH:MM, ISO 8601 const timeFormats = [ /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, // HH:MM /^\d{4}-\d{2}-\d{2} ([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, // YYYY-MM-DD HH:MM /^\d{4}-\d{2}-\d{2}T([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.\d{3})?Z?$/ // ISO 8601 ]; const isValidFormat = timeFormats.some(format => format.test(trimmed)); if (!isValidFormat) { result.isValid = false; result.errors.push('Invalid time format. Expected HH:MM, YYYY-MM-DD HH:MM, or ISO 8601 format'); } return result; } /** * Validate reference string */ public static validateReference(reference: string): { isValid: boolean; errors: string[] } { const result = { isValid: true, errors: [] as string[] }; if (!reference || typeof reference !== 'string') { // Reference can be optional return result; } const trimmed = reference.trim(); if (trimmed.length > 200) { result.isValid = false; result.errors.push('Reference cannot exceed 200 characters'); } // Check for dangerous patterns for (const pattern of this.DANGEROUS_PATTERNS) { if (pattern.test(trimmed)) { result.isValid = false; result.errors.push('Reference contains potentially dangerous content'); } } return result; } /** * Comprehensive input validation for project creation */ public static validateProjectInputs(inputs: { projectIdea: string; projectName: string; projectPath: string; qualityRequirements: string; teamSizePriority?: string; preferredStartTime?: string; reference?: string; }): { isValid: boolean; errors: string[] } { const result = { isValid: true, errors: [] as string[] }; // Validate project idea const projectIdeaValidation = this.validateProjectIdea(inputs.projectIdea); if (!projectIdeaValidation.isValid) { result.isValid = false; result.errors.push(...projectIdeaValidation.errors.map(err => `Project Idea: ${err}`)); } // Validate project name const projectNameValidation = this.validateProjectName(inputs.projectName); if (!projectNameValidation.isValid) { result.isValid = false; result.errors.push(...projectNameValidation.errors.map(err => `Project Name: ${err}`)); } // Validate project path const projectPathValidation = this.sanitizeFilePath(inputs.projectPath); if (!projectPathValidation.isValid) { result.isValid = false; result.errors.push(...projectPathValidation.errors.map(err => `Project Path: ${err}`)); } // Validate quality requirements const qualityValidation = this.validateQualityRequirements(inputs.qualityRequirements); if (!qualityValidation.isValid) { result.isValid = false; result.errors.push(...qualityValidation.errors.map(err => `Quality Requirements: ${err}`)); } // Validate optional fields if (inputs.preferredStartTime) { const timeValidation = this.validateTimeString(inputs.preferredStartTime); if (!timeValidation.isValid) { result.isValid = false; result.errors.push(...timeValidation.errors.map(err => `Start Time: ${err}`)); } } if (inputs.reference) { const referenceValidation = this.validateReference(inputs.reference); if (!referenceValidation.isValid) { result.isValid = false; result.errors.push(...referenceValidation.errors.map(err => `Reference: ${err}`)); } } return result; } }