@neuroequalityorg/knightcode
Version:
Knightcode CLI - Your local AI coding assistant using Ollama, LM Studio, and more
414 lines (354 loc) • 11.5 kB
text/typescript
/**
* Execution Environment Module
*
* Provides functionality for executing shell commands and scripts
* in a controlled environment with proper error handling.
*/
import { exec, spawn } from 'child_process';
import { logger } from '../utils/logger.js';
import { createUserError } from '../errors/formatter.js';
import { ErrorCategory } from '../errors/types.js';
import { Timeout } from '../utils/types.js';
/**
* Result of a command execution
*/
interface ExecutionResult {
output: string;
exitCode: number;
error?: Error;
command: string;
duration: number;
}
/**
* Command execution options
*/
interface ExecutionOptions {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
shell?: string;
maxBuffer?: number;
captureStderr?: boolean;
}
/**
* Background process options
*/
interface BackgroundProcessOptions extends ExecutionOptions {
onOutput?: (output: string) => void;
onError?: (error: string) => void;
onExit?: (code: number | null) => void;
}
/**
* Background process handle
*/
interface BackgroundProcess {
pid: number;
kill: () => boolean;
isRunning: boolean;
}
/**
* List of dangerous commands that shouldn't be executed
*/
const DANGEROUS_COMMANDS = [
/^\s*rm\s+(-rf?|--recursive)\s+[\/~]/i, // rm -rf / or similar
/^\s*dd\s+.*of=\/dev\/(disk|hd|sd)/i, // dd to a device
/^\s*mkfs/i, // Format a filesystem
/^\s*:\(\)\{\s*:\|:\s*&\s*\}\s*;/, // Fork bomb
/^\s*>(\/dev\/sd|\/dev\/hd)/, // Overwrite disk device
/^\s*sudo\s+.*(rm|mkfs|dd|chmod|chown)/i // sudo with dangerous commands
];
/**
* Maximum command execution time (30 seconds by default)
*/
const DEFAULT_TIMEOUT = 30000;
/**
* Maximum output buffer size (5MB by default)
*/
const DEFAULT_MAX_BUFFER = 5 * 1024 * 1024;
/**
* Execution environment manager
*/
class ExecutionEnvironment {
private config: any;
private backgroundProcesses: Map<number, BackgroundProcess> = new Map();
private executionCount: number = 0;
private workingDirectory: string;
private environmentVariables: Record<string, string>;
/**
* Create a new execution environment
*/
constructor(config: any) {
this.config = config;
this.workingDirectory = config.execution?.cwd || process.cwd();
// Set up environment variables
this.environmentVariables = {
...process.env as Record<string, string>,
CLAUDE_CODE_VERSION: config.version || '0.2.29',
NODE_ENV: config.env || 'production',
...(config.execution?.env || {})
};
logger.debug('Execution environment created', {
workingDirectory: this.workingDirectory
});
}
/**
* Initialize the execution environment
*/
async initialize(): Promise<void> {
logger.info('Initializing execution environment');
try {
// Verify shell is available
const shell = this.config.execution?.shell || process.env.SHELL || 'bash';
await this.executeCommand(`${shell} -c "echo Shell is available"`, {
timeout: 5000
});
logger.info('Execution environment initialized successfully');
} catch (error) {
logger.error('Failed to initialize execution environment', error);
throw createUserError('Failed to initialize command execution environment', {
cause: error,
category: ErrorCategory.COMMAND_EXECUTION,
resolution: 'Check that your shell is properly configured'
});
}
}
/**
* Execute a shell command
*/
async executeCommand(command: string, options: ExecutionOptions = {}): Promise<ExecutionResult> {
// Increment execution count
this.executionCount++;
// Validate command for safety
this.validateCommand(command);
const cwd = options.cwd || this.workingDirectory;
const env = { ...this.environmentVariables, ...(options.env || {}) };
const timeout = options.timeout || DEFAULT_TIMEOUT;
const maxBuffer = options.maxBuffer || DEFAULT_MAX_BUFFER;
const shell = options.shell || this.config.execution?.shell || process.env.SHELL || 'bash';
const captureStderr = options.captureStderr !== false;
logger.debug('Executing command', {
command,
cwd,
shell,
timeout,
executionCount: this.executionCount
});
const startTime = Date.now();
return new Promise<ExecutionResult>((resolve, reject) => {
exec(command, {
cwd,
env,
timeout,
maxBuffer,
shell,
windowsHide: true,
encoding: 'utf8'
}, (error: Error | null, stdout: string, stderr: string) => {
const duration = Date.now() - startTime;
// Combine stdout and stderr if requested
const output = captureStderr ? `${stdout}${stderr ? stderr : ''}` : stdout;
if (error) {
logger.error(`Command execution failed: ${command}`, {
error: error.message,
exitCode: (error as any).code,
duration
});
// Format the error result
resolve({
output,
exitCode: (error as any).code || 1,
error,
command,
duration
});
} else {
logger.debug(`Command executed successfully: ${command}`, {
duration,
outputLength: output.length
});
resolve({
output,
exitCode: 0,
command,
duration
});
}
});
});
}
/**
* Execute a command in the background
*/
executeCommandInBackground(command: string, options: BackgroundProcessOptions = {}): BackgroundProcess {
// Validate command for safety
this.validateCommand(command);
const cwd = options.cwd || this.workingDirectory;
const env = { ...this.environmentVariables, ...(options.env || {}) };
const shell = options.shell || this.config.execution?.shell || process.env.SHELL || 'bash';
logger.debug('Executing command in background', {
command,
cwd,
shell
});
// Spawn the process
const childProcess = spawn(command, [], {
cwd,
env,
shell,
detached: true,
stdio: ['ignore', 'pipe', 'pipe']
});
const pid = childProcess.pid!;
let isRunning = true;
// Set up output handlers
if (childProcess.stdout) {
childProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString('utf8');
logger.debug(`Background command (pid ${pid}) output:`, { output });
if (options.onOutput) {
options.onOutput(output);
}
});
}
if (childProcess.stderr) {
childProcess.stderr.on('data', (data: Buffer) => {
const errorOutput = data.toString('utf8');
logger.debug(`Background command (pid ${pid}) error:`, { errorOutput });
if (options.onError) {
options.onError(errorOutput);
}
});
}
// Set up exit handler
childProcess.on('exit', (code) => {
isRunning = false;
logger.debug(`Background command (pid ${pid}) exited with code ${code}`);
// Remove from tracked processes
this.backgroundProcesses.delete(pid);
if (options.onExit) {
options.onExit(code);
}
});
// Create the process handle
const backgroundProcess: BackgroundProcess = {
pid,
kill: () => {
if (isRunning) {
childProcess.kill();
isRunning = false;
this.backgroundProcesses.delete(pid);
return true;
}
return false;
},
isRunning: true
};
// Track the process
this.backgroundProcesses.set(pid, backgroundProcess);
return backgroundProcess;
}
/**
* Kill all running background processes
*/
killAllBackgroundProcesses(): void {
logger.info(`Killing ${this.backgroundProcesses.size} background processes`);
for (const process of this.backgroundProcesses.values()) {
try {
process.kill();
} catch (error) {
logger.warn(`Failed to kill process ${process.pid}`, error);
}
}
this.backgroundProcesses.clear();
}
/**
* Validate a command for safety
*/
private validateCommand(command: string): void {
// Check if command is in the denied list
for (const pattern of DANGEROUS_COMMANDS) {
if (pattern.test(command)) {
throw createUserError(`Command execution blocked: '${command}' matches dangerous pattern`, {
category: ErrorCategory.COMMAND_EXECUTION,
resolution: 'This command is blocked for safety reasons. Please use a different command.'
});
}
}
// Check if command is in allowed list (if configured)
if (this.config.execution?.allowedCommands && this.config.execution.allowedCommands.length > 0) {
const allowed = this.config.execution.allowedCommands.some(
(allowedPattern: string | RegExp) => {
if (typeof allowedPattern === 'string') {
return command.startsWith(allowedPattern);
} else {
return allowedPattern.test(command);
}
}
);
if (!allowed) {
throw createUserError(`Command execution blocked: '${command}' is not in the allowed list`, {
category: ErrorCategory.COMMAND_EXECUTION,
resolution: 'This command is not allowed by your configuration.'
});
}
}
}
/**
* Set the working directory
*/
setWorkingDirectory(directory: string): void {
this.workingDirectory = directory;
logger.debug(`Working directory set to: ${directory}`);
}
/**
* Get the working directory
*/
getWorkingDirectory(): string {
return this.workingDirectory;
}
/**
* Set an environment variable
*/
setEnvironmentVariable(name: string, value: string): void {
this.environmentVariables[name] = value;
logger.debug(`Environment variable set: ${name}=${value}`);
}
/**
* Get an environment variable
*/
getEnvironmentVariable(name: string): string | undefined {
return this.environmentVariables[name];
}
}
/**
* Initialize the execution environment
*/
export async function initExecutionEnvironment(config: any): Promise<ExecutionEnvironment> {
logger.info('Initializing execution environment');
try {
const executionEnv = new ExecutionEnvironment(config);
await executionEnv.initialize();
logger.info('Execution environment initialized successfully');
return executionEnv;
} catch (error) {
logger.error('Failed to initialize execution environment', error);
// Return a minimal execution environment even if initialization failed
return new ExecutionEnvironment(config);
}
}
// Set up cleanup on process exit
function setupProcessCleanup(executionEnv: ExecutionEnvironment): void {
process.on('exit', () => {
executionEnv.killAllBackgroundProcesses();
});
process.on('SIGINT', () => {
executionEnv.killAllBackgroundProcesses();
process.exit(0);
});
process.on('SIGTERM', () => {
executionEnv.killAllBackgroundProcesses();
process.exit(0);
});
}
export { ExecutionResult, ExecutionOptions, BackgroundProcess, BackgroundProcessOptions };
export default ExecutionEnvironment;