UNPKG

@sirmrmarty/n8n-nodes-tmux-orchestrator

Version:

n8n nodes for orchestrating Claude AI agents through tmux sessions

315 lines (269 loc) 9.24 kB
/** * Secure Tmux Wrapper * Provides secure tmux operations with comprehensive input validation */ import { CommandSanitizer, SanitizedCommand } from './commandSanitizer'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); export interface TmuxExecutionResult { success: boolean; stdout: string; stderr: string; command: string; timestamp: string; } export interface TmuxExecutionOptions { timeout?: number; maxBuffer?: number; cwd?: string; env?: Record<string, string>; } export class SecureTmuxWrapper { private static readonly DEFAULT_TIMEOUT = 30000; // 30 seconds private static readonly DEFAULT_MAX_BUFFER = 1024 * 1024; // 1MB private static readonly TMUX_BINARY = 'tmux'; /** * Execute a tmux command securely */ public static async executeSecurely( command: string, args: string[], options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const startTime = new Date().toISOString(); try { // Sanitize the command and arguments const sanitized = CommandSanitizer.prepareTmuxExecution(command, args); // Build the full command const fullCommand = this.buildCommand(sanitized); // Set execution options const execOptions = { timeout: options.timeout || this.DEFAULT_TIMEOUT, maxBuffer: options.maxBuffer || this.DEFAULT_MAX_BUFFER, cwd: options.cwd, env: { ...process.env, ...options.env } }; // Execute the command const { stdout, stderr } = await execAsync(fullCommand, execOptions); return { success: true, stdout: stdout || '', stderr: stderr || '', command: fullCommand, timestamp: startTime }; } catch (error: any) { return { success: false, stdout: error.stdout || '', stderr: error.stderr || error.message || 'Unknown error', command: `${this.TMUX_BINARY} ${command} ${args.join(' ')}`, timestamp: startTime }; } } /** * Send keys to a tmux window securely */ public static async sendKeysSecurely( sessionName: string, windowIndex: number, keys: string, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { // Validate session name and window index const target = CommandSanitizer.createSafeTmuxTarget(sessionName, windowIndex); // Sanitize keys input const sanitizedKeys = this.sanitizeKeys(keys); return this.executeSecurely('send-keys', ['-t', target, sanitizedKeys], options); } /** * Send a command to a tmux window securely */ public static async sendCommandSecurely( sessionName: string, windowIndex: number, command: string, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { // Validate session name and window index const target = CommandSanitizer.createSafeTmuxTarget(sessionName, windowIndex); // Sanitize command input const sanitizedCommand = this.sanitizeKeys(command); return this.executeSecurely('send-keys', ['-t', target, sanitizedCommand, 'Enter'], options); } /** * Create a new tmux session securely */ public static async createSessionSecurely( sessionName: string, initialDirectory?: string, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const sessionValidation = CommandSanitizer.validateSessionName(sessionName); if (!sessionValidation.isValid) { throw new Error(`Invalid session name: ${sessionValidation.errors.join(', ')}`); } const args = ['-d', '-s', sessionName]; if (initialDirectory) { const pathValidation = CommandSanitizer.sanitizeFilePath(initialDirectory); if (!pathValidation.isValid) { throw new Error(`Invalid directory path: ${pathValidation.errors.join(', ')}`); } args.push('-c', pathValidation.path); } return this.executeSecurely('new-session', args, options); } /** * Create a new tmux window securely */ public static async createWindowSecurely( sessionName: string, windowName: string, initialDirectory?: string, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const sessionValidation = CommandSanitizer.validateSessionName(sessionName); if (!sessionValidation.isValid) { throw new Error(`Invalid session name: ${sessionValidation.errors.join(', ')}`); } const windowValidation = CommandSanitizer.validateProjectName(windowName); if (!windowValidation.isValid) { throw new Error(`Invalid window name: ${windowValidation.errors.join(', ')}`); } const args = ['-t', sessionName, '-n', windowName]; if (initialDirectory) { const pathValidation = CommandSanitizer.sanitizeFilePath(initialDirectory); if (!pathValidation.isValid) { throw new Error(`Invalid directory path: ${pathValidation.errors.join(', ')}`); } args.push('-c', pathValidation.path); } return this.executeSecurely('new-window', args, options); } /** * Capture pane content securely */ public static async capturePaneSecurely( sessionName: string, windowIndex: number, startLine?: number, endLine?: number, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const target = CommandSanitizer.createSafeTmuxTarget(sessionName, windowIndex); const args = ['-t', target, '-p']; if (startLine !== undefined && endLine !== undefined) { // Validate line numbers if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 0 || endLine < 0 || startLine > endLine) { throw new Error('Invalid line range: start and end must be non-negative integers with start <= end'); } args.push('-S', startLine.toString(), '-E', endLine.toString()); } return this.executeSecurely('capture-pane', args, options); } /** * List sessions securely */ public static async listSessionsSecurely(options: TmuxExecutionOptions = {}): Promise<TmuxExecutionResult> { return this.executeSecurely('list-sessions', ['-F', '#{session_name}:#{session_windows}:#{session_created}'], options); } /** * List windows for a session securely */ public static async listWindowsSecurely( sessionName: string, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const sessionValidation = CommandSanitizer.validateSessionName(sessionName); if (!sessionValidation.isValid) { throw new Error(`Invalid session name: ${sessionValidation.errors.join(', ')}`); } return this.executeSecurely('list-windows', ['-t', sessionName, '-F', '#{window_index}:#{window_name}:#{window_active}'], options); } /** * Kill a tmux session securely */ public static async killSessionSecurely( sessionName: string, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const sessionValidation = CommandSanitizer.validateSessionName(sessionName); if (!sessionValidation.isValid) { throw new Error(`Invalid session name: ${sessionValidation.errors.join(', ')}`); } return this.executeSecurely('kill-session', ['-t', sessionName], options); } /** * Check if a session exists securely */ public static async hasSessionSecurely( sessionName: string, options: TmuxExecutionOptions = {} ): Promise<boolean> { const sessionValidation = CommandSanitizer.validateSessionName(sessionName); if (!sessionValidation.isValid) { throw new Error(`Invalid session name: ${sessionValidation.errors.join(', ')}`); } const result = await this.executeSecurely('has-session', ['-t', sessionName], options); return result.success; } /** * Kill a tmux window securely */ public static async killWindowSecurely( sessionName: string, windowIndex: number, options: TmuxExecutionOptions = {} ): Promise<TmuxExecutionResult> { const target = CommandSanitizer.createSafeTmuxTarget(sessionName, windowIndex); return this.executeSecurely('kill-window', ['-t', target], options); } /** * Build the full tmux command string */ private static buildCommand(sanitized: SanitizedCommand): string { const escapedArgs = sanitized.args.map(arg => this.escapeShellArgument(arg)); return `${this.TMUX_BINARY} ${sanitized.command} ${escapedArgs.join(' ')}`; } /** * Sanitize keys input to prevent injection */ private static sanitizeKeys(keys: string): string { if (!keys || typeof keys !== 'string') { throw new Error('Keys must be a non-empty string'); } // Remove or escape dangerous characters let sanitized = keys .replace(/[;&|`$(){}]/g, '') // Remove shell metacharacters .replace(/\\/g, '\\\\') // Escape backslashes .trim(); if (sanitized.length === 0) { throw new Error('Keys cannot be empty after sanitization'); } if (sanitized.length > 10000) { throw new Error('Keys exceed maximum length of 10000 characters'); } return sanitized; } /** * Escape shell argument for safe execution */ private static escapeShellArgument(arg: string): string { // Use single quotes to prevent shell interpretation return `'${arg.replace(/'/g, "'\"'\"'")}'`; } /** * Validate tmux is available on the system */ public static async validateTmuxAvailable(): Promise<boolean> { try { const result = await execAsync(`${this.TMUX_BINARY} -V`, { timeout: 5000 }); return result.stdout.includes('tmux'); } catch (error) { return false; } } }