200 lines
5.6 KiB
TypeScript
200 lines
5.6 KiB
TypeScript
// 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<string> {
|
|
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();
|