// src/tools/file/shell.ts // Copyright (C) 2025 DTP Technologies, LLC // All Rights Reserved import { exec } from "node:child_process"; import { promisify } from "node:util"; import type { ToolDefinition } from "../../lib/ai-client.js"; import { DtpTool, type ToolArguments, type ToolContext, } from "../../lib/tool.js"; import { ChatSessionMode } from "@/models/chat-session.js"; import { validateWorkingDirectory } from "../../lib/path-security.js"; import { PROJECT_ROOT } from "../../config/env.js"; import HostMonitorService from "../../services/host-monitor.js"; const execAsync = promisify(exec); const DEFAULT_TIMEOUT = 30_000; // 30 seconds const MAX_TIMEOUT = 120_000; // 120 seconds export class ShellExecTool extends DtpTool { get name(): string { return "ShellExecTool"; } get slug(): string { return "shell-exec"; } get metadata() { return { name: this.definition.function.name || "shell_exec", category: "file", tags: ["shell", "command", "exec", "run", "process"], modes: [ ChatSessionMode.Plan, ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop, ], }; } public definition: ToolDefinition = { type: "function", function: { name: "shell_exec", description: "Execute a shell command and capture its output. Returns stdout, stderr, and exit code. Use with caution — commands run with the same permissions as this process.", parameters: { type: "object", properties: { command: { type: "string", description: "The shell command to execute.", }, cwd: { type: "string", description: "Working directory for the command. Defaults to current directory.", }, timeout: { type: "number", description: `Timeout in milliseconds. Default: ${DEFAULT_TIMEOUT / 1000}s, max: ${MAX_TIMEOUT / 1000}s.`, }, }, required: ["command"], }, }, }; public async execute( _context: ToolContext, args: ToolArguments, ): Promise { const command = args.command as string | undefined; if (!command || command.trim().length === 0) { return this.error("MISSING_PARAMETER", "Command must not be empty.", { parameter: "command", recoveryHint: "Provide a valid shell command to execute.", }); } // Validate cwd if provided - prevent path traversal attacks let resolvedCwd: string; if (args.cwd !== undefined) { const cwdPath = args.cwd as string; const cwdValidation = validateWorkingDirectory(cwdPath, PROJECT_ROOT); if (!cwdValidation.valid || !cwdValidation.resolvedPath) { return this.error( "SECURITY_VIOLATION", cwdValidation.error || "Invalid working directory", { parameter: "cwd", recoveryHint: "Provide a valid relative path within the project directory.", }, ); } resolvedCwd = cwdValidation.resolvedPath; } else { resolvedCwd = process.cwd(); } const timeout = Math.min( (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT, MAX_TIMEOUT, ); try { const { stdout, stderr } = await execAsync(command, { cwd: resolvedCwd, timeout, maxBuffer: 1024 * 1024 * 10, // 10MB }); const output = this.formatOutput(command, stdout, stderr, 0); const byteCount = Buffer.byteLength(output, "utf-8"); HostMonitorService.toolCall(byteCount); return this.success({ command, exitCode: 0 }, output); } catch (error) { if (error instanceof Error) { const nodeError = error as NodeJS.ErrnoException & { code?: string; stdout?: string; stderr?: string; }; if ( nodeError.code === "ETIMEDOUT" || nodeError.message.includes("timeout") ) { return this.error( "TIMEOUT", `Command timed out after ${timeout / 1000}s: ${command}`, { parameter: "timeout", recoveryHint: `Increase the timeout parameter (max ${MAX_TIMEOUT / 1000}s) or break the command into smaller steps.`, }, ); } const exitCode = typeof nodeError.code === "string" ? parseInt(nodeError.code.replace("ERR_", ""), 10) : 1; const stdout = nodeError.stdout ?? ""; const stderr = nodeError.stderr ?? nodeError.message; const output = this.formatOutput( command, stdout, stderr, isNaN(exitCode) ? 1 : exitCode, ); const byteCount = Buffer.byteLength(output, "utf-8"); HostMonitorService.toolCall(byteCount); return this.success( { command, exitCode: isNaN(exitCode) ? 1 : exitCode }, output, ); } this.log.error("Failed to execute command", { command, error: String(error), }); return this.error( "OPERATION_FAILED", `Failed to execute command: ${String(error)}`, ); } } private formatOutput( command: string, stdout: string, stderr: string, exitCode: number, ): string { const parts: string[] = []; parts.push(`COMMAND: ${command}`); parts.push(`EXIT CODE: ${exitCode}`); parts.push(`---[stdout]`); parts.push(stdout || "N/A"); parts.push(`---[stderr]`); parts.push(stderr || "N/A"); return parts.join("\n"); } } export default new ShellExecTool();