super-shell-mcp
Version:
MCP server for executing shell commands across multiple platforms
318 lines • 12.3 kB
JavaScript
import { execFile } from 'child_process';
import { promisify } from 'util';
import { randomUUID } from 'crypto';
import { EventEmitter } from 'events';
import * as path from 'path';
import { getDefaultShell } from '../utils/platform-utils.js';
import { getPlatformSpecificCommands } from '../utils/command-whitelist-utils.js';
const execFileAsync = promisify(execFile);
/**
* Command security level classification
*/
export var CommandSecurityLevel;
(function (CommandSecurityLevel) {
/** Safe commands that can be executed without approval */
CommandSecurityLevel["SAFE"] = "safe";
/** Commands that require approval before execution */
CommandSecurityLevel["REQUIRES_APPROVAL"] = "requires_approval";
/** Commands that are explicitly forbidden */
CommandSecurityLevel["FORBIDDEN"] = "forbidden";
})(CommandSecurityLevel || (CommandSecurityLevel = {}));
/**
* Service for securely executing shell commands
*/
export class CommandService extends EventEmitter {
/** Shell to use for commands */
shell;
/** Whether shell parsing is enabled */
useShell;
/** Command whitelist */
whitelist;
/** Pending commands awaiting approval */
pendingCommands;
/** Default timeout for command execution in milliseconds */
defaultTimeout;
/**
* Create a new CommandService
* @param options Command service configuration options
*/
constructor(options = {}) {
super();
this.shell = options.shell || getDefaultShell();
this.useShell = options.useShell ?? false;
this.whitelist = new Map();
this.pendingCommands = new Map();
this.defaultTimeout = options.defaultTimeout ?? 30000;
// Initialize with platform-specific commands
this.initializeDefaultWhitelist();
}
/**
* Get the current shell being used
* @returns The shell path
*/
getShell() {
return this.shell;
}
/**
* Whether command execution uses a shell for parsing
* @returns True if shell execution is enabled
*/
isShellEnabled() {
return this.useShell;
}
/**
* Initialize the default command whitelist based on the current platform
*/
initializeDefaultWhitelist() {
// Get platform-specific commands
const platformCommands = getPlatformSpecificCommands();
// Add all commands to the whitelist
platformCommands.forEach(entry => {
this.whitelist.set(entry.command, entry);
});
}
/**
* Add a command to the whitelist
* @param entry The command whitelist entry
*/
addToWhitelist(entry) {
this.whitelist.set(entry.command, entry);
}
/**
* Remove a command from the whitelist
* @param command The command to remove
*/
removeFromWhitelist(command) {
this.whitelist.delete(command);
}
/**
* Update a command's security level
* @param command The command to update
* @param securityLevel The new security level
*/
updateSecurityLevel(command, securityLevel) {
const entry = this.whitelist.get(command);
if (entry) {
entry.securityLevel = securityLevel;
this.whitelist.set(command, entry);
}
}
/**
* Get all whitelisted commands
* @returns Array of command whitelist entries
*/
getWhitelist() {
return Array.from(this.whitelist.values());
}
/**
* Get all pending commands awaiting approval
* @returns Array of pending commands
*/
getPendingCommands() {
return Array.from(this.pendingCommands.values());
}
/**
* Validate if a command and its arguments are allowed
* @param command The command to validate
* @param args The command arguments
* @returns The security level of the command or null if not whitelisted
*/
validateCommand(command, args) {
// Extract the base command (without path) using path.basename
const baseCommand = path.basename(command);
// Check if the command is in the whitelist
const entry = this.whitelist.get(baseCommand);
if (!entry) {
return null;
}
// If the command is forbidden, return immediately
if (entry.securityLevel === CommandSecurityLevel.FORBIDDEN) {
return CommandSecurityLevel.FORBIDDEN;
}
// If there are allowed arguments defined, validate them
if (entry.allowedArgs && entry.allowedArgs.length > 0) {
// Check if all arguments are allowed
const allArgsValid = args.every((arg, index) => {
// If we have more args than allowed patterns, reject
if (index >= (entry.allowedArgs?.length || 0)) {
return false;
}
const pattern = entry.allowedArgs?.[index];
if (!pattern) {
return false;
}
// Check if the argument matches the pattern
if (typeof pattern === 'string') {
return arg === pattern;
}
else {
return pattern.test(arg);
}
});
if (!allArgsValid) {
return CommandSecurityLevel.REQUIRES_APPROVAL;
}
}
return entry.securityLevel;
}
/**
* Execute a shell command
* @param command The command to execute
* @param args Command arguments
* @param options Additional options
* @returns Promise resolving to command output
*/
async executeCommand(command, args = [], options = {}) {
const securityLevel = this.validateCommand(command, args);
// If command is not whitelisted, reject
if (securityLevel === null) {
throw new Error(`Command not whitelisted: ${command}`);
}
// If command is forbidden, reject
if (securityLevel === CommandSecurityLevel.FORBIDDEN) {
throw new Error(`Command is forbidden: ${command}`);
}
// If command requires approval, add to pending queue
if (securityLevel === CommandSecurityLevel.REQUIRES_APPROVAL) {
return this.queueCommandForApproval(command, args, options.requestedBy);
}
// For safe commands, execute immediately
try {
const timeout = options.timeout || this.defaultTimeout;
const { stdout, stderr } = await execFileAsync(command, args, {
timeout,
shell: this.useShell ? this.shell : false
});
return { stdout, stderr };
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Command execution failed: ${error.message}`);
}
throw error;
}
}
/**
* Queue a command for approval
* @param command The command to queue
* @param args Command arguments
* @param requestedBy Who requested the command
* @returns Promise resolving when command is approved and executed
*/
queueCommandForApproval(command, args = [], requestedBy) {
return new Promise((resolve, reject) => {
const id = randomUUID();
const pendingCommand = {
id,
command,
args,
requestedAt: new Date(),
requestedBy,
resolve: (result) => resolve(result),
reject: (error) => reject(error)
};
this.pendingCommands.set(id, pendingCommand);
// Emit event for pending command
this.emit('command:pending', pendingCommand);
// Set a timeout to check if the command is still pending after a while
// This helps detect if the UI approval didn't properly trigger the approveCommand method
setTimeout(() => {
// If the command is still pending after the timeout
if (this.pendingCommands.has(id)) {
// Emit a warning event that can be handled by the client
this.emit('command:approval_timeout', {
commandId: id,
message: 'Command approval timed out. If you approved this command in the UI, please use get_pending_commands and approve_command to complete the process.'
});
}
}, 5000); // 5 second timeout to detect UI approval issues
});
}
/**
* Queue a command for approval without waiting for the Promise to resolve
* @param command The command to queue
* @param args Command arguments
* @param requestedBy Who requested the command
* @returns The ID of the queued command
*/
queueCommandForApprovalNonBlocking(command, args = [], requestedBy) {
const id = randomUUID();
const pendingCommand = {
id,
command,
args,
requestedAt: new Date(),
requestedBy,
resolve: () => { }, // No-op resolve function
reject: () => { } // No-op reject function
};
this.pendingCommands.set(id, pendingCommand);
// Emit event for pending command
this.emit('command:pending', pendingCommand);
// Set a timeout to check if the command is still pending after a while
setTimeout(() => {
// If the command is still pending after the timeout
if (this.pendingCommands.has(id)) {
// Emit a warning event that can be handled by the client
this.emit('command:approval_timeout', {
commandId: id,
message: 'Command approval timed out. If you approved this command in the UI, please use get_pending_commands and approve_command to complete the process.'
});
}
}, 5000); // 5 second timeout to detect UI approval issues
return id;
}
/**
* Approve a pending command
* @param commandId ID of the command to approve
* @returns Promise resolving to command output
*/
async approveCommand(commandId) {
const pendingCommand = this.pendingCommands.get(commandId);
if (!pendingCommand) {
throw new Error(`No pending command with ID: ${commandId}`);
}
try {
const { stdout, stderr } = await execFileAsync(pendingCommand.command, pendingCommand.args, { shell: this.useShell ? this.shell : false });
// Remove from pending queue
this.pendingCommands.delete(commandId);
// Emit event for approved command
this.emit('command:approved', { commandId, stdout, stderr });
// Resolve the original promise
pendingCommand.resolve({ stdout, stderr });
return { stdout, stderr };
}
catch (error) {
// Remove from pending queue
this.pendingCommands.delete(commandId);
// Emit event for failed command
this.emit('command:failed', { commandId, error });
if (error instanceof Error) {
// Reject the original promise
pendingCommand.reject(error);
throw error;
}
const genericError = new Error('Command execution failed');
pendingCommand.reject(genericError);
throw genericError;
}
}
/**
* Deny a pending command
* @param commandId ID of the command to deny
* @param reason Reason for denial
*/
denyCommand(commandId, reason = 'Command denied') {
const pendingCommand = this.pendingCommands.get(commandId);
if (!pendingCommand) {
throw new Error(`No pending command with ID: ${commandId}`);
}
// Remove from pending queue
this.pendingCommands.delete(commandId);
// Emit event for denied command
this.emit('command:denied', { commandId, reason });
// Reject the original promise
pendingCommand.reject(new Error(reason));
}
}
//# sourceMappingURL=command-service.js.map