/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'fs'; import path from 'path'; import os from 'os'; import crypto from 'crypto'; import { Config } from '../config/config.js'; import { BaseTool, ToolResult, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, ToolConfirmationOutcome, } from './tools.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { getErrorMessage } from '../utils/errors.js'; export interface ShellToolParams { command: string; description?: string; directory?: string; } import { spawn } from 'child_process'; const OUTPUT_UPDATE_INTERVAL_MS = 1000; export class ShellTool extends BaseTool { static Name: string = 'execute_bash_command'; private whitelist: Set = new Set(); constructor(private readonly config: Config) { const toolDisplayName = 'Shell'; const descriptionUrl = new URL('shell.md', import.meta.url); const toolDescription = fs.readFileSync(descriptionUrl, 'utf-8'); const schemaUrl = new URL('shell.json', import.meta.url); const toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8')); super( ShellTool.Name, toolDisplayName, toolDescription, toolParameterSchema, false, // output is not markdown true, // output can be updated ); } getDescription(params: ShellToolParams): string { let description = `${params.command}`; // append optional [in directory] // note description is needed even if validation fails due to absolute path if (params.directory) { description += ` [in ${params.directory}]`; } // append optional (description), replacing any line breaks with spaces if (params.description) { description += ` (${params.description.replace(/\n/g, ' ')})`; } return description; } getCommandRoot(command: string): string | undefined { return command .trim() // remove leading and trailing whitespace .replace(/[{}()]/g, '') // remove all grouping operators .split(/[\s;&|]+/)[0] // split on any whitespace or separator or chaining operators and take first part ?.split(/[/\\]/) // split on any path separators (or return undefined if previous line was undefined) .pop(); // take last part and return command root (or undefined if previous line was empty) } validateToolParams(params: ShellToolParams): string | null { if ( !SchemaValidator.validate( this.parameterSchema as Record, params, ) ) { return `Parameters failed schema validation.`; } if (!params.command.trim()) { return 'Command cannot be empty.'; } if (!this.getCommandRoot(params.command)) { return 'Could not identify command root to obtain permission from user.'; } if (params.directory) { if (path.isAbsolute(params.directory)) { return 'Directory cannot be absolute. Must be relative to the project root directory.'; } const directory = path.resolve( this.config.getTargetDir(), params.directory, ); if (!fs.existsSync(directory)) { return 'Directory must exist.'; } } return null; } async shouldConfirmExecute( params: ShellToolParams, _abortSignal: AbortSignal, ): Promise { if (this.validateToolParams(params)) { return false; // skip confirmation, execute call will fail immediately } const rootCommand = this.getCommandRoot(params.command)!; // must be non-empty string post-validation if (this.whitelist.has(rootCommand)) { return false; // already approved and whitelisted } const confirmationDetails: ToolExecuteConfirmationDetails = { type: 'exec', title: 'Confirm Shell Command', command: params.command, rootCommand, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.whitelist.add(rootCommand); } }, }; return confirmationDetails; } async execute( params: ShellToolParams, abortSignal: AbortSignal, updateOutput?: (chunk: string) => void, ): Promise { const validationError = this.validateToolParams(params); if (validationError) { return { llmContent: [ `Command rejected: ${params.command}`, `Reason: ${validationError}`, ].join('\n'), returnDisplay: `Error: ${validationError}`, }; } // wrap command to append subprocess pids (via pgrep) to temporary file const tempFileName = `shell_pgrep_${crypto.randomBytes(6).toString('hex')}.tmp`; const tempFilePath = path.join(os.tmpdir(), tempFileName); let command = params.command.trim(); if (!command.endsWith('&')) command += ';'; command = `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; // spawn command in specified directory (or project root if not specified) const shell = spawn('bash', ['-c', command], { stdio: ['ignore', 'pipe', 'pipe'], detached: true, // ensure subprocess starts its own process group (esp. in Linux) cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), }); let exited = false; let stdout = ''; let output = ''; let lastUpdateTime = Date.now(); const appendOutput = (str: string) => { output += str; if ( updateOutput && Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS ) { updateOutput(output); lastUpdateTime = Date.now(); } }; shell.stdout.on('data', (data: Buffer) => { // continue to consume post-exit for background processes // removing listeners can overflow OS buffer and block subprocesses // destroying (e.g. shell.stdout.destroy()) can terminate subprocesses via SIGPIPE if (!exited) { const str = data.toString(); stdout += str; appendOutput(str); } }); let stderr = ''; shell.stderr.on('data', (data: Buffer) => { if (!exited) { const str = data.toString(); stderr += str; appendOutput(str); } }); let error: Error | null = null; shell.on('error', (err: Error) => { error = err; // remove wrapper from user's command in error message error.message = error.message.replace(command, params.command); }); let code: number | null = null; let processSignal: NodeJS.Signals | null = null; const exitHandler = ( _code: number | null, _signal: NodeJS.Signals | null, ) => { exited = true; code = _code; processSignal = _signal; }; shell.on('exit', exitHandler); const abortHandler = async () => { if (shell.pid && !exited) { try { // attempt to SIGTERM process group (negative PID) // fall back to SIGKILL (to group) after 200ms process.kill(-shell.pid, 'SIGTERM'); await new Promise((resolve) => setTimeout(resolve, 200)); if (shell.pid && !exited) { process.kill(-shell.pid, 'SIGKILL'); } } catch (_e) { // if group kill fails, fall back to killing just the main process try { if (shell.pid) { shell.kill('SIGKILL'); } } catch (_e) { console.error(`failed to kill shell process ${shell.pid}: ${_e}`); } } } }; abortSignal.addEventListener('abort', abortHandler); // wait for the shell to exit await new Promise((resolve) => shell.on('exit', resolve)); abortSignal.removeEventListener('abort', abortHandler); // parse pids (pgrep output) from temporary file and remove it const backgroundPIDs: number[] = []; if (fs.existsSync(tempFilePath)) { const pgrepLines = fs .readFileSync(tempFilePath, 'utf8') .split('\n') .filter(Boolean); for (const line of pgrepLines) { if (!/^\d+$/.test(line)) { console.error(`pgrep: ${line}`); } const pid = Number(line); // exclude the shell subprocess pid if (pid !== shell.pid) { backgroundPIDs.push(pid); } } fs.unlinkSync(tempFilePath); } else { if (!abortSignal.aborted) { console.error('missing pgrep output'); } } let llmContent = ''; if (abortSignal.aborted) { llmContent = 'Command was cancelled by user before it could complete.'; if (output.trim()) { llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${output}`; } else { llmContent += ' There was no output before it was cancelled.'; } } else { llmContent = [ `Command: ${params.command}`, `Directory: ${params.directory || '(root)'}`, `Stdout: ${stdout || '(empty)'}`, `Stderr: ${stderr || '(empty)'}`, `Error: ${error ?? '(none)'}`, `Exit Code: ${code ?? '(none)'}`, `Signal: ${processSignal ?? '(none)'}`, `Background PIDs: ${backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'}`, ].join('\n'); } let returnDisplayMessage = ''; if (this.config.getDebugMode()) { returnDisplayMessage = llmContent; } else { if (output.trim()) { returnDisplayMessage = output; } else { // Output is empty, let's provide a reason if the command failed or was cancelled if (abortSignal.aborted) { returnDisplayMessage = 'Command cancelled by user.'; } else if (processSignal) { returnDisplayMessage = `Command terminated by signal: ${processSignal}`; } else if (error) { // If error is not null, it's an Error object (or other truthy value) returnDisplayMessage = `Command failed: ${getErrorMessage(error)}`; } else if (code !== null && code !== 0) { returnDisplayMessage = `Command exited with code: ${code}`; } // If output is empty and command succeeded (code 0, no error/signal/abort), // returnDisplayMessage will remain empty, which is fine. } } return { llmContent, returnDisplay: returnDisplayMessage }; } }