diff --git a/package.json b/package.json index 3ea07452..6e6cb081 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "react": "^19.2.4", "ripgrep-node": "^1.0.0", "tiktoken": "^1.0.22", - "ws": "^8.19.0" + "ws": "^8.19.0", + "shell-quote": "^1.8.3" }, "devDependencies": { "@types/fs-extra": "^11.0.4", diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 14a7215a..9c5ac51d 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -1,14 +1,88 @@ import { ConfirmationService } from "../utils/confirmation-service"; import { ToolResult } from "../types/index"; -import { exec } from "child_process"; +import { spawn } from "child_process"; import { promisify } from "util"; +import * as shellQuote from "shell-quote"; -const execAsync = promisify(exec); +const promisifyFn = promisify; // kept in case other parts of this file evolve to use promisify export class BashTool { private currentDirectory: string = process.cwd(); private confirmationService = ConfirmationService.getInstance(); + private async runCommandSafely( + command: string, + options: { cwd?: string; timeout?: number; maxBuffer?: number }, + ): Promise<{ stdout: string; stderr: string }> { + const parsed = shellQuote.parse(command); + const argv = parsed + .filter(token => typeof token === "string") + .map(token => token as string); + + if (argv.length === 0) { + return { stdout: "", stderr: "" }; + } + + const cmd = argv[0]; + const args = argv.slice(1); + + return await new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: options.cwd, + shell: false, + }); + + let stdout = ""; + let stderr = ""; + const maxBuffer = options.maxBuffer ?? 1024 * 1024; + const timeout = options.timeout ?? 30000; + + let killedForTimeout = false; + let killedForBuffer = false; + + const timer = setTimeout(() => { + killedForTimeout = true; + child.kill(); + }, timeout); + + child.stdout?.on("data", (data: Buffer | string) => { + const chunk = data.toString(); + if (stdout.length + chunk.length > maxBuffer) { + killedForBuffer = true; + child.kill(); + return; + } + stdout += chunk; + }); + + child.stderr?.on("data", (data: Buffer | string) => { + const chunk = data.toString(); + if (stderr.length + chunk.length > maxBuffer) { + killedForBuffer = true; + child.kill(); + return; + } + stderr += chunk; + }); + + child.on("error", error => { + clearTimeout(timer); + reject(error); + }); + + child.on("close", code => { + clearTimeout(timer); + if (killedForTimeout) { + return reject(new Error("Command timed out")); + } + if (killedForBuffer) { + return reject(new Error("Command output exceeded buffer limit")); + } + resolve({ stdout, stderr }); + }); + }); + } + async execute(command: string, timeout: number = 30000): Promise { try { // Check if user has already accepted bash commands for this session @@ -53,7 +127,7 @@ export class BashTool { } } - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await this.runCommandSafely(command, { cwd: this.currentDirectory, timeout, maxBuffer: 1024 * 1024,