/**
* Safe command execution wrapper
*/
import { spawn } from "child_process";
import { ExecutionResult, ExecutionOptions } from "../types.js";
import { DEFAULT_TIMEOUT, MAX_OUTPUT_SIZE, TOOL_TIMEOUTS } from "../constants.js";
import { validateCommand } from "./validator.js";
/**
* Execute a command safely with timeout and output limits
*/
export async function executeCommand(
command: string,
args: string[],
options: ExecutionOptions = {}
): Promise<ExecutionResult> {
const startTime = Date.now();
// Validate command is in allowlist
if (!validateCommand(command)) {
return {
success: false,
exitCode: -1,
stdout: "",
stderr: `Command '${command}' is not allowed`,
command: `${command} ${args.join(" ")}`,
duration: 0,
timedOut: false,
};
}
// Set defaults
const timeout = options.timeout || TOOL_TIMEOUTS[command] || DEFAULT_TIMEOUT;
const maxOutputSize = options.maxOutputSize || MAX_OUTPUT_SIZE;
const cwd = options.cwd || process.cwd();
const env = options.env || process.env;
// Log execution to stderr for audit trail
console.error(`[EXEC] ${command} ${args.join(" ")}`);
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let killed = false;
let timedOut = false;
// Spawn process
const child = spawn(command, args, {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
});
// Set timeout
const timeoutId = setTimeout(() => {
timedOut = true;
killed = true;
child.kill("SIGTERM");
// Force kill after 5 seconds
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, 5000);
}, timeout);
// Capture stdout
child.stdout?.on("data", (data: Buffer) => {
const chunk = data.toString();
if (stdout.length + chunk.length > maxOutputSize) {
stderr += "\n[OUTPUT TRUNCATED: Maximum output size exceeded]";
child.kill("SIGTERM");
killed = true;
} else {
stdout += chunk;
}
});
// Capture stderr
child.stderr?.on("data", (data: Buffer) => {
const chunk = data.toString();
if (stderr.length + chunk.length > maxOutputSize) {
stderr += "\n[ERROR OUTPUT TRUNCATED: Maximum output size exceeded]";
child.kill("SIGTERM");
killed = true;
} else {
stderr += chunk;
}
});
// Handle process exit
child.on("close", (code, _signal) => {
clearTimeout(timeoutId);
const endTime = Date.now();
const duration = endTime - startTime;
const result: ExecutionResult = {
success: code === 0 && !killed,
exitCode: code ?? -1,
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
command: `${command} ${args.join(" ")}`,
duration,
timedOut,
};
// Log completion
console.error(
`[EXEC] Completed in ${duration}ms with code ${code}${timedOut ? " (timeout)" : ""}`
);
resolve(result);
});
// Handle spawn errors
child.on("error", (error) => {
clearTimeout(timeoutId);
const endTime = Date.now();
const duration = endTime - startTime;
console.error(`[EXEC] Error: ${error.message}`);
resolve({
success: false,
exitCode: -1,
stdout: "",
stderr: `Failed to execute command: ${error.message}`,
command: `${command} ${args.join(" ")}`,
duration,
timedOut: false,
});
});
});
}
/**
* Execute command with progress callback
*/
export async function executeWithProgress(
command: string,
args: string[],
onProgress: (line: string) => void,
options: ExecutionOptions = {}
): Promise<ExecutionResult> {
const startTime = Date.now();
if (!validateCommand(command)) {
return {
success: false,
exitCode: -1,
stdout: "",
stderr: `Command '${command}' is not allowed`,
command: `${command} ${args.join(" ")}`,
duration: 0,
timedOut: false,
};
}
const timeout = options.timeout || TOOL_TIMEOUTS[command] || DEFAULT_TIMEOUT;
const maxOutputSize = options.maxOutputSize || MAX_OUTPUT_SIZE;
const cwd = options.cwd || process.cwd();
const env = options.env || process.env;
console.error(`[EXEC] ${command} ${args.join(" ")}`);
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let killed = false;
let timedOut = false;
let currentLine = "";
const child = spawn(command, args, {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
});
const timeoutId = setTimeout(() => {
timedOut = true;
killed = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, 5000);
}, timeout);
child.stdout?.on("data", (data: Buffer) => {
const chunk = data.toString();
if (stdout.length + chunk.length > maxOutputSize) {
stderr += "\n[OUTPUT TRUNCATED]";
child.kill("SIGTERM");
killed = true;
} else {
stdout += chunk;
// Process line by line
currentLine += chunk;
const lines = currentLine.split("\n");
currentLine = lines.pop() || "";
for (const line of lines) {
if (line.trim()) {
onProgress(line);
}
}
}
});
child.stderr?.on("data", (data: Buffer) => {
const chunk = data.toString();
if (stderr.length + chunk.length > maxOutputSize) {
stderr += "\n[ERROR OUTPUT TRUNCATED]";
child.kill("SIGTERM");
killed = true;
} else {
stderr += chunk;
}
});
child.on("close", (code) => {
clearTimeout(timeoutId);
// Process final line if any
if (currentLine.trim()) {
onProgress(currentLine);
}
const endTime = Date.now();
const duration = endTime - startTime;
resolve({
success: code === 0 && !killed,
exitCode: code ?? -1,
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
command: `${command} ${args.join(" ")}`,
duration,
timedOut,
});
});
child.on("error", (error) => {
clearTimeout(timeoutId);
resolve({
success: false,
exitCode: -1,
stdout: "",
stderr: `Failed to execute command: ${error.message}`,
command: `${command} ${args.join(" ")}`,
duration: Date.now() - startTime,
timedOut: false,
});
});
});
}
/**
* Check if a command is available in PATH
*/
export async function isCommandAvailable(command: string): Promise<boolean> {
const result = await executeCommand("which", [command], { timeout: 5000 });
return result.success && result.stdout.trim().length > 0;
}
/**
* Get command version
*/
export async function getCommandVersion(command: string): Promise<string | null> {
// Try common version flags
const versionFlags = ["--version", "-v", "-V", "version"];
for (const flag of versionFlags) {
const result = await executeCommand(command, [flag], { timeout: 5000 });
if (result.success || result.stdout.length > 0) {
// Extract version from first line
const firstLine = (result.stdout || result.stderr).split("\n")[0];
return firstLine?.trim() || null;
}
}
return null;
}
/**
* Build command arguments safely
*/
export function buildArgs(params: Record<string, any>): string[] {
const args: string[] = [];
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) {
continue;
}
if (typeof value === "boolean") {
if (value) {
args.push(`--${key}`);
}
} else if (Array.isArray(value)) {
for (const item of value) {
args.push(`--${key}`, String(item));
}
} else {
args.push(`--${key}`, String(value));
}
}
return args;
}