gadget/docs/archive/tools/file/shell.ts

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();