@sirmrmarty/n8n-nodes-tmux-orchestrator
Version:
n8n nodes for orchestrating Claude AI agents through tmux sessions
315 lines (269 loc) • 9.24 kB
text/typescript
/**
* 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;
}
}
}