504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
/**
|
|
* @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 { Type } from '@google/genai';
|
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
|
import { getErrorMessage } from '../utils/errors.js';
|
|
import stripAnsi from 'strip-ansi';
|
|
|
|
export interface ShellToolParams {
|
|
command: string;
|
|
description?: string;
|
|
directory?: string;
|
|
}
|
|
import { spawn } from 'child_process';
|
|
import { summarizeToolOutput } from '../utils/summarizer.js';
|
|
|
|
const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
|
|
|
export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
|
static Name: string = 'run_shell_command';
|
|
private whitelist: Set<string> = new Set();
|
|
|
|
constructor(private readonly config: Config) {
|
|
super(
|
|
ShellTool.Name,
|
|
'Shell',
|
|
`This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
|
|
|
The following information is returned:
|
|
|
|
Command: Executed command.
|
|
Directory: Directory (relative to project root) where command was executed, or \`(root)\`.
|
|
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
|
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
|
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
|
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
|
Signal: Signal number or \`(none)\` if no signal was received.
|
|
Background PIDs: List of background processes started or \`(none)\`.
|
|
Process Group PGID: Process group started or \`(none)\``,
|
|
{
|
|
type: Type.OBJECT,
|
|
properties: {
|
|
command: {
|
|
type: Type.STRING,
|
|
description: 'Exact bash command to execute as `bash -c <command>`',
|
|
},
|
|
description: {
|
|
type: Type.STRING,
|
|
description:
|
|
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
|
},
|
|
directory: {
|
|
type: Type.STRING,
|
|
description:
|
|
'(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.',
|
|
},
|
|
},
|
|
required: ['command'],
|
|
},
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Extracts the root command from a given shell command string.
|
|
* This is used to identify the base command for permission checks.
|
|
*
|
|
* @param command The shell command string to parse
|
|
* @returns The root command name, or undefined if it cannot be determined
|
|
* @example getCommandRoot("ls -la /tmp") returns "ls"
|
|
* @example getCommandRoot("git status && npm test") returns "git"
|
|
*/
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Determines whether a given shell command is allowed to execute based on
|
|
* the tool's configuration including allowlists and blocklists.
|
|
*
|
|
* @param command The shell command string to validate
|
|
* @returns An object with 'allowed' boolean and optional 'reason' string if not allowed
|
|
*/
|
|
isCommandAllowed(command: string): { allowed: boolean; reason?: string } {
|
|
// 0. Disallow command substitution
|
|
if (command.includes('$(')) {
|
|
return {
|
|
allowed: false,
|
|
reason:
|
|
'Command substitution using $() is not allowed for security reasons',
|
|
};
|
|
}
|
|
|
|
const SHELL_TOOL_NAMES = [ShellTool.name, ShellTool.Name];
|
|
|
|
const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
|
|
|
|
/**
|
|
* Checks if a command string starts with a given prefix, ensuring it's a
|
|
* whole word match (i.e., followed by a space or it's an exact match).
|
|
* e.g., `isPrefixedBy('npm install', 'npm')` -> true
|
|
* e.g., `isPrefixedBy('npm', 'npm')` -> true
|
|
* e.g., `isPrefixedBy('npminstall', 'npm')` -> false
|
|
*/
|
|
const isPrefixedBy = (cmd: string, prefix: string): boolean => {
|
|
if (!cmd.startsWith(prefix)) {
|
|
return false;
|
|
}
|
|
return cmd.length === prefix.length || cmd[prefix.length] === ' ';
|
|
};
|
|
|
|
/**
|
|
* Extracts and normalizes shell commands from a list of tool strings.
|
|
* e.g., 'ShellTool("ls -l")' becomes 'ls -l'
|
|
*/
|
|
const extractCommands = (tools: string[]): string[] =>
|
|
tools.flatMap((tool) => {
|
|
for (const toolName of SHELL_TOOL_NAMES) {
|
|
if (tool.startsWith(`${toolName}(`) && tool.endsWith(')')) {
|
|
return [normalize(tool.slice(toolName.length + 1, -1))];
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const coreTools = this.config.getCoreTools() || [];
|
|
const excludeTools = this.config.getExcludeTools() || [];
|
|
|
|
// 1. Check if the shell tool is globally disabled.
|
|
if (SHELL_TOOL_NAMES.some((name) => excludeTools.includes(name))) {
|
|
return {
|
|
allowed: false,
|
|
reason: 'Shell tool is globally disabled in configuration',
|
|
};
|
|
}
|
|
|
|
const blockedCommands = new Set(extractCommands(excludeTools));
|
|
const allowedCommands = new Set(extractCommands(coreTools));
|
|
|
|
const hasSpecificAllowedCommands = allowedCommands.size > 0;
|
|
const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) =>
|
|
coreTools.includes(name),
|
|
);
|
|
|
|
const commandsToValidate = command.split(/&&|\|\||\||;/).map(normalize);
|
|
|
|
const blockedCommandsArr = [...blockedCommands];
|
|
|
|
for (const cmd of commandsToValidate) {
|
|
// 2. Check if the command is on the blocklist.
|
|
const isBlocked = blockedCommandsArr.some((blocked) =>
|
|
isPrefixedBy(cmd, blocked),
|
|
);
|
|
if (isBlocked) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Command '${cmd}' is blocked by configuration`,
|
|
};
|
|
}
|
|
|
|
// 3. If in strict allow-list mode, check if the command is permitted.
|
|
const isStrictAllowlist =
|
|
hasSpecificAllowedCommands && !isWildcardAllowed;
|
|
const allowedCommandsArr = [...allowedCommands];
|
|
if (isStrictAllowlist) {
|
|
const isAllowed = allowedCommandsArr.some((allowed) =>
|
|
isPrefixedBy(cmd, allowed),
|
|
);
|
|
if (!isAllowed) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Command '${cmd}' is not in the allowed commands list`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. If all checks pass, the command is allowed.
|
|
return { allowed: true };
|
|
}
|
|
|
|
validateToolParams(params: ShellToolParams): string | null {
|
|
const commandCheck = this.isCommandAllowed(params.command);
|
|
if (!commandCheck.allowed) {
|
|
if (!commandCheck.reason) {
|
|
console.error(
|
|
'Unexpected: isCommandAllowed returned false without a reason',
|
|
);
|
|
return `Command is not allowed: ${params.command}`;
|
|
}
|
|
return commandCheck.reason;
|
|
}
|
|
const errors = SchemaValidator.validate(this.schema.parameters, params);
|
|
if (errors) {
|
|
return errors;
|
|
}
|
|
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<ToolCallConfirmationDetails | false> {
|
|
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<ToolResult> {
|
|
const validationError = this.validateToolParams(params);
|
|
if (validationError) {
|
|
return {
|
|
llmContent: [
|
|
`Command rejected: ${params.command}`,
|
|
`Reason: ${validationError}`,
|
|
].join('\n'),
|
|
returnDisplay: `Error: ${validationError}`,
|
|
};
|
|
}
|
|
|
|
if (abortSignal.aborted) {
|
|
return {
|
|
llmContent: 'Command was cancelled by user before it could start.',
|
|
returnDisplay: 'Command cancelled by user.',
|
|
};
|
|
}
|
|
|
|
const isWindows = os.platform() === 'win32';
|
|
const tempFileName = `shell_pgrep_${crypto
|
|
.randomBytes(6)
|
|
.toString('hex')}.tmp`;
|
|
const tempFilePath = path.join(os.tmpdir(), tempFileName);
|
|
|
|
// pgrep is not available on Windows, so we can't get background PIDs
|
|
const command = isWindows
|
|
? params.command
|
|
: (() => {
|
|
// wrap command to append subprocess pids (via pgrep) to temporary file
|
|
let command = params.command.trim();
|
|
if (!command.endsWith('&')) command += ';';
|
|
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
|
})();
|
|
|
|
// spawn command in specified directory (or project root if not specified)
|
|
const shell = isWindows
|
|
? spawn('cmd.exe', ['/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 || ''),
|
|
})
|
|
: 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 = stripAnsi(data.toString());
|
|
stdout += str;
|
|
appendOutput(str);
|
|
}
|
|
});
|
|
|
|
let stderr = '';
|
|
shell.stderr.on('data', (data: Buffer) => {
|
|
if (!exited) {
|
|
const str = stripAnsi(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) {
|
|
if (os.platform() === 'win32') {
|
|
// For Windows, use taskkill to kill the process tree
|
|
spawn('taskkill', ['/pid', shell.pid.toString(), '/f', '/t']);
|
|
} else {
|
|
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
|
|
try {
|
|
await new Promise((resolve) => shell.on('exit', resolve));
|
|
} finally {
|
|
abortSignal.removeEventListener('abort', abortHandler);
|
|
}
|
|
|
|
// parse pids (pgrep output) from temporary file and remove it
|
|
const backgroundPIDs: number[] = [];
|
|
if (os.platform() !== 'win32') {
|
|
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)'}`,
|
|
`Process Group PGID: ${shell.pid ?? '(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.
|
|
}
|
|
}
|
|
|
|
const summary = await summarizeToolOutput(
|
|
llmContent,
|
|
this.config.getGeminiClient(),
|
|
abortSignal,
|
|
);
|
|
|
|
return {
|
|
llmContent: summary,
|
|
returnDisplay: returnDisplayMessage,
|
|
};
|
|
}
|
|
}
|