claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
547 lines (489 loc) • 12.7 kB
text/typescript
/**
* Safe Executor - HIGH-1 Remediation
*
* Fixes command injection vulnerabilities by:
* - Using execFile instead of exec with shell
* - Validating all command arguments
* - Implementing command allowlist
* - Sanitizing command inputs
*
* Security Properties:
* - No shell interpretation
* - Argument validation
* - Command allowlist enforcement
* - Timeout controls
* - Resource limits
*
* @module v3/security/safe-executor
*/
import { execFile, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import * as path from 'path';
const execFileAsync = promisify(execFile);
export interface ExecutorConfig {
/**
* Allowed commands (allowlist).
* Only commands in this list can be executed.
*/
allowedCommands: string[];
/**
* Blocked argument patterns (regex strings).
* Arguments matching these patterns are rejected.
*/
blockedPatterns?: string[];
/**
* Maximum execution timeout in milliseconds.
* Default: 30000 (30 seconds)
*/
timeout?: number;
/**
* Maximum buffer size for stdout/stderr.
* Default: 10MB
*/
maxBuffer?: number;
/**
* Working directory for command execution.
* Default: process.cwd()
*/
cwd?: string;
/**
* Environment variables to include.
* Default: process.env
*/
env?: NodeJS.ProcessEnv;
/**
* Whether to allow sudo commands.
* Default: false
*/
allowSudo?: boolean;
}
export interface ExecutionResult {
stdout: string;
stderr: string;
exitCode: number;
command: string;
args: string[];
duration: number;
}
export interface StreamingExecutor {
process: ChildProcess;
stdout: NodeJS.ReadableStream | null;
stderr: NodeJS.ReadableStream | null;
promise: Promise<ExecutionResult>;
}
export class SafeExecutorError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly command?: string,
public readonly args?: string[],
) {
super(message);
this.name = 'SafeExecutorError';
}
}
/**
* Default blocked argument patterns.
* These patterns indicate potential command injection attempts.
*/
const DEFAULT_BLOCKED_PATTERNS = [
// Shell metacharacters
';',
'&&',
'||',
'|',
'`',
'$(',
'${',
// Redirection
'>',
'<',
'>>',
// Background execution
'&',
// Newlines (command chaining)
'\n',
'\r',
// Null byte injection
'\0',
// Command substitution
'$()',
];
/**
* Commands that are inherently dangerous and should never be allowed.
*/
const DANGEROUS_COMMANDS = [
'rm',
'rmdir',
'del',
'format',
'mkfs',
'dd',
'chmod',
'chown',
'kill',
'killall',
'pkill',
'reboot',
'shutdown',
'init',
'poweroff',
'halt',
];
/**
* Safe command executor that prevents command injection.
*
* This class replaces unsafe exec() and spawn({shell: true}) calls
* with validated execFile() calls.
*
* @example
* ```typescript
* const executor = new SafeExecutor({
* allowedCommands: ['git', 'npm', 'node']
* });
*
* const result = await executor.execute('git', ['status']);
* ```
*/
export class SafeExecutor {
private readonly config: Required<ExecutorConfig>;
private readonly blockedPatterns: RegExp[];
constructor(config: ExecutorConfig) {
this.config = {
allowedCommands: config.allowedCommands,
blockedPatterns: config.blockedPatterns ?? DEFAULT_BLOCKED_PATTERNS,
timeout: config.timeout ?? 30000,
maxBuffer: config.maxBuffer ?? 10 * 1024 * 1024, // 10MB
cwd: config.cwd ?? process.cwd(),
env: config.env ?? process.env,
allowSudo: config.allowSudo ?? false,
};
// Compile blocked patterns for performance
this.blockedPatterns = this.config.blockedPatterns.map(
pattern => new RegExp(this.escapeRegExp(pattern), 'i')
);
this.validateConfig();
}
/**
* Escapes special regex characters.
*/
private escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Validates executor configuration.
*/
private validateConfig(): void {
if (this.config.allowedCommands.length === 0) {
throw new SafeExecutorError(
'At least one allowed command must be specified',
'EMPTY_ALLOWLIST'
);
}
// Check for dangerous commands in allowlist
const dangerousAllowed = this.config.allowedCommands.filter(
cmd => DANGEROUS_COMMANDS.includes(path.basename(cmd))
);
if (dangerousAllowed.length > 0) {
throw new SafeExecutorError(
`Dangerous commands cannot be allowed: ${dangerousAllowed.join(', ')}`,
'DANGEROUS_COMMAND_ALLOWED'
);
}
}
/**
* Validates a command against the allowlist.
*
* @param command - Command to validate
* @throws SafeExecutorError if command is not allowed
*/
private validateCommand(command: string): void {
const basename = path.basename(command);
// Check if command is allowed
const isAllowed = this.config.allowedCommands.some(allowed => {
const allowedBasename = path.basename(allowed);
return command === allowed || basename === allowedBasename;
});
if (!isAllowed) {
throw new SafeExecutorError(
`Command not in allowlist: ${command}`,
'COMMAND_NOT_ALLOWED',
command
);
}
// Check for sudo
if (!this.config.allowSudo && (command === 'sudo' || basename === 'sudo')) {
throw new SafeExecutorError(
'Sudo commands are not allowed',
'SUDO_NOT_ALLOWED',
command
);
}
}
/**
* Validates command arguments for injection patterns.
*
* @param args - Arguments to validate
* @throws SafeExecutorError if arguments contain dangerous patterns
*/
private validateArguments(args: string[]): void {
for (const arg of args) {
// Check for null bytes
if (arg.includes('\0')) {
throw new SafeExecutorError(
'Null byte detected in argument',
'NULL_BYTE_INJECTION',
undefined,
args
);
}
// Check against blocked patterns
for (const pattern of this.blockedPatterns) {
if (pattern.test(arg)) {
throw new SafeExecutorError(
`Dangerous pattern detected in argument: ${arg}`,
'DANGEROUS_PATTERN',
undefined,
args
);
}
}
// Check for command chaining attempts
if (/^-.*[;&|]/.test(arg)) {
throw new SafeExecutorError(
`Potential command chaining in argument: ${arg}`,
'COMMAND_CHAINING',
undefined,
args
);
}
}
}
/**
* Sanitizes a single argument.
*
* @param arg - Argument to sanitize
* @returns Sanitized argument
*/
sanitizeArgument(arg: string): string {
// Remove null bytes
let sanitized = arg.replace(/\0/g, '');
// Remove shell metacharacters
sanitized = sanitized.replace(/[;&|`$(){}><\n\r]/g, '');
return sanitized;
}
/**
* Executes a command safely.
*
* @param command - Command to execute (must be in allowlist)
* @param args - Command arguments
* @returns Execution result
* @throws SafeExecutorError on validation failure or execution error
*/
async execute(command: string, args: string[] = []): Promise<ExecutionResult> {
const startTime = Date.now();
// Validate command
this.validateCommand(command);
// Validate arguments
this.validateArguments(args);
try {
// Execute command WITHOUT shell
const { stdout, stderr } = await execFileAsync(command, args, {
cwd: this.config.cwd,
env: this.config.env,
timeout: this.config.timeout,
maxBuffer: this.config.maxBuffer,
shell: false, // CRITICAL: Never use shell
windowsHide: true,
});
return {
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: 0,
command,
args,
duration: Date.now() - startTime,
};
} catch (error: any) {
// Handle execution errors
if (error.killed) {
throw new SafeExecutorError(
'Command execution timed out',
'TIMEOUT',
command,
args
);
}
if (error.code === 'ENOENT') {
throw new SafeExecutorError(
`Command not found: ${command}`,
'COMMAND_NOT_FOUND',
command,
args
);
}
// Return result with non-zero exit code
return {
stdout: error.stdout?.toString() ?? '',
stderr: error.stderr?.toString() ?? error.message,
exitCode: error.code ?? 1,
command,
args,
duration: Date.now() - startTime,
};
}
}
/**
* Executes a command with streaming output.
*
* @param command - Command to execute
* @param args - Command arguments
* @returns Streaming executor with process handles
*/
executeStreaming(command: string, args: string[] = []): StreamingExecutor {
const startTime = Date.now();
// Validate command
this.validateCommand(command);
// Validate arguments
this.validateArguments(args);
// Spawn process WITHOUT shell
const childProcess = spawn(command, args, {
cwd: this.config.cwd,
env: this.config.env,
timeout: this.config.timeout,
shell: false, // CRITICAL: Never use shell
windowsHide: true,
});
const promise = new Promise<ExecutionResult>((resolve, reject) => {
let stdout = '';
let stderr = '';
childProcess.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
resolve({
stdout,
stderr,
exitCode: code ?? 0,
command,
args,
duration: Date.now() - startTime,
});
});
childProcess.on('error', (error) => {
reject(new SafeExecutorError(
error.message,
'EXECUTION_ERROR',
command,
args
));
});
});
return {
process: childProcess,
stdout: childProcess.stdout,
stderr: childProcess.stderr,
promise,
};
}
/**
* Adds a command to the allowlist at runtime.
*
* @param command - Command to add
*/
allowCommand(command: string): void {
const basename = path.basename(command);
if (DANGEROUS_COMMANDS.includes(basename)) {
throw new SafeExecutorError(
`Cannot allow dangerous command: ${command}`,
'DANGEROUS_COMMAND'
);
}
if (!this.config.allowedCommands.includes(command)) {
this.config.allowedCommands.push(command);
}
}
/**
* Checks if a command is allowed.
*
* @param command - Command to check
* @returns True if command is allowed
*/
isCommandAllowed(command: string): boolean {
const basename = path.basename(command);
return this.config.allowedCommands.some(allowed => {
const allowedBasename = path.basename(allowed);
return command === allowed || basename === allowedBasename;
});
}
/**
* Returns the current allowlist.
*/
getAllowedCommands(): readonly string[] {
return [...this.config.allowedCommands];
}
}
/**
* Factory function to create a safe executor for common development tasks.
*
* @returns Configured SafeExecutor for git, npm, and node
*/
export function createDevelopmentExecutor(): SafeExecutor {
return new SafeExecutor({
allowedCommands: [
'git',
'npm',
'node',
'tsc',
'vitest',
'eslint',
'prettier',
],
});
}
/**
* Factory function to create a CLI executor.
* Allows commands commonly needed by CLI command handlers.
*
* @returns Configured SafeExecutor for CLI operations
*/
export function createCliExecutor(): SafeExecutor {
return new SafeExecutor({
allowedCommands: [
'git',
'npm',
'npx',
'node',
'docker',
'which',
'tsc',
'vitest',
],
timeout: 60000,
});
}
/**
* Factory function to create a read-only executor.
* Only allows commands that read without modifying.
*
* @returns Configured SafeExecutor for read operations
*/
export function createReadOnlyExecutor(): SafeExecutor {
return new SafeExecutor({
allowedCommands: [
'git',
'cat',
'head',
'tail',
'ls',
'find',
'grep',
'which',
'echo',
],
timeout: 10000,
});
}