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