Files
gemini-cli/packages/core/src/tools/shell.ts

504 lines
17 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
2025-04-27 18:57:10 -07:00
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';
2025-04-27 18:57:10 -07:00
import { SchemaValidator } from '../utils/schemaValidator.js';
import { getErrorMessage } from '../utils/errors.js';
2025-06-02 14:50:12 -07:00
import stripAnsi from 'strip-ansi';
export interface ShellToolParams {
command: string;
description?: string;
2025-04-27 18:57:10 -07:00
directory?: string;
}
2025-04-27 18:57:10 -07:00
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\`.
Ignore folders files (#651) # Add .gitignore-Aware File Filtering to gemini-cli This pull request introduces .gitignore-based file filtering to the gemini-cli, ensuring that git-ignored files are automatically excluded from file-related operations and suggestions throughout the CLI. The update enhances usability, reduces noise from build artifacts and dependencies, and provides new configuration options for fine-tuning file discovery. Key Improvements .gitignore File Filtering All @ (at) commands, file completions, and core discovery tools now honor .gitignore patterns by default. Git-ignored files (such as node_modules/, dist/, .env, and .git) are excluded from results unless explicitly overridden. The behavior can be customized via a new fileFiltering section in settings.json, including options for: Turning .gitignore respect on/off. Adding custom ignore patterns. Allowing or excluding build artifacts. Configuration & Documentation Updates settings.json schema extended with fileFiltering options. Documentation updated to explain new filtering controls and usage patterns. Testing New and updated integration/unit tests for file filtering logic, configuration merging, and edge cases. Test coverage ensures .gitignore filtering works as intended across different workflows. Internal Refactoring Core file discovery logic refactored for maintainability and extensibility. Underlying tools (ls, glob, read-many-files) now support git-aware filtering out of the box. Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-06-03 21:40:46 -07:00
The following information is returned:
Ignore folders files (#651) # Add .gitignore-Aware File Filtering to gemini-cli This pull request introduces .gitignore-based file filtering to the gemini-cli, ensuring that git-ignored files are automatically excluded from file-related operations and suggestions throughout the CLI. The update enhances usability, reduces noise from build artifacts and dependencies, and provides new configuration options for fine-tuning file discovery. Key Improvements .gitignore File Filtering All @ (at) commands, file completions, and core discovery tools now honor .gitignore patterns by default. Git-ignored files (such as node_modules/, dist/, .env, and .git) are excluded from results unless explicitly overridden. The behavior can be customized via a new fileFiltering section in settings.json, including options for: Turning .gitignore respect on/off. Adding custom ignore patterns. Allowing or excluding build artifacts. Configuration & Documentation Updates settings.json schema extended with fileFiltering options. Documentation updated to explain new filtering controls and usage patterns. Testing New and updated integration/unit tests for file filtering logic, configuration merging, and edge cases. Test coverage ensures .gitignore filtering works as intended across different workflows. Internal Refactoring Core file discovery logic refactored for maintainability and extensibility. Underlying tools (ls, glob, read-many-files) now support git-aware filtering out of the box. Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-06-03 21:40:46 -07:00
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,
Ignore folders files (#651) # Add .gitignore-Aware File Filtering to gemini-cli This pull request introduces .gitignore-based file filtering to the gemini-cli, ensuring that git-ignored files are automatically excluded from file-related operations and suggestions throughout the CLI. The update enhances usability, reduces noise from build artifacts and dependencies, and provides new configuration options for fine-tuning file discovery. Key Improvements .gitignore File Filtering All @ (at) commands, file completions, and core discovery tools now honor .gitignore patterns by default. Git-ignored files (such as node_modules/, dist/, .env, and .git) are excluded from results unless explicitly overridden. The behavior can be customized via a new fileFiltering section in settings.json, including options for: Turning .gitignore respect on/off. Adding custom ignore patterns. Allowing or excluding build artifacts. Configuration & Documentation Updates settings.json schema extended with fileFiltering options. Documentation updated to explain new filtering controls and usage patterns. Testing New and updated integration/unit tests for file filtering logic, configuration merging, and edge cases. Test coverage ensures .gitignore filtering works as intended across different workflows. Internal Refactoring Core file discovery logic refactored for maintainability and extensibility. Underlying tools (ls, glob, read-many-files) now support git-aware filtering out of the box. Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-06-03 21:40:46 -07:00
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.',
},
Ignore folders files (#651) # Add .gitignore-Aware File Filtering to gemini-cli This pull request introduces .gitignore-based file filtering to the gemini-cli, ensuring that git-ignored files are automatically excluded from file-related operations and suggestions throughout the CLI. The update enhances usability, reduces noise from build artifacts and dependencies, and provides new configuration options for fine-tuning file discovery. Key Improvements .gitignore File Filtering All @ (at) commands, file completions, and core discovery tools now honor .gitignore patterns by default. Git-ignored files (such as node_modules/, dist/, .env, and .git) are excluded from results unless explicitly overridden. The behavior can be customized via a new fileFiltering section in settings.json, including options for: Turning .gitignore respect on/off. Adding custom ignore patterns. Allowing or excluding build artifacts. Configuration & Documentation Updates settings.json schema extended with fileFiltering options. Documentation updated to explain new filtering controls and usage patterns. Testing New and updated integration/unit tests for file filtering logic, configuration merging, and edge cases. Test coverage ensures .gitignore filtering works as intended across different workflows. Internal Refactoring Core file discovery logic refactored for maintainability and extensibility. Underlying tools (ls, glob, read-many-files) now support git-aware filtering out of the box. Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-06-03 21:40:46 -07:00
},
required: ['command'],
},
false, // output is not markdown
true, // output can be updated
);
}
getDescription(params: ShellToolParams): string {
2025-04-27 18:57:10 -07:00
let description = `${params.command}`;
// append optional [in directory]
// note description is needed even if validation fails due to absolute path
2025-04-28 08:17:52 -07:00
if (params.directory) {
description += ` [in ${params.directory}]`;
2025-04-28 08:17:52 -07:00
}
// append optional (description), replacing any line breaks with spaces
2025-04-27 18:57:10 -07:00
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"
*/
2025-04-27 18:57:10 -07:00
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 };
}
2025-04-27 18:57:10 -07:00
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;
2025-04-27 18:57:10 -07:00
}
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> {
2025-04-27 18:57:10 -07:00
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)) {
2025-04-27 18:57:10 -07:00
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> {
2025-04-27 18:57:10 -07:00
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;`;
})();
2025-04-27 18:57:10 -07:00
// 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 || ''),
});
2025-04-27 18:57:10 -07:00
let exited = false;
2025-04-27 18:57:10 -07:00
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();
}
};
2025-04-27 18:57:10 -07:00
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) {
2025-06-02 14:50:12 -07:00
const str = stripAnsi(data.toString());
stdout += str;
appendOutput(str);
2025-05-27 15:40:18 -07:00
}
2025-04-27 18:57:10 -07:00
});
let stderr = '';
shell.stderr.on('data', (data: Buffer) => {
if (!exited) {
2025-06-02 14:50:12 -07:00
const str = stripAnsi(data.toString());
stderr += str;
appendOutput(str);
2025-04-27 18:57:10 -07:00
}
});
let error: Error | null = null;
shell.on('error', (err: Error) => {
error = err;
2025-05-21 09:31:13 -07:00
// remove wrapper from user's command in error message
error.message = error.message.replace(command, params.command);
2025-04-27 18:57:10 -07:00
});
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);
2025-04-27 18:57:10 -07:00
// wait for the shell to exit
Jacob314/memory fixes (#754) Address multiple possible memory leaks found bystatic analysis of the codebase. The primary source of the leaks was event listeners on child processes and global objects that were not being properly removed, potentially causing their closures to be retained in memory indefinitely particularly for processes that did not exit. There are two commits. A larger one made by gemini CLI and a smaller one by me to make sure we always disconnect child processes as part of the cleanup methods. These changes may not actually fix any leaks but do look like reasonable defensive coding to avoid leaking event listeners or child processes. The following files were fixed: This is Gemini's somewhat overconfident description of what it did. packages/core/src/tools/shell.ts: Fixed a leak where an abortSignal listener was not being reliably removed. packages/cli/src/utils/readStdin.ts: Fixed a significant leak where listeners on process.stdin were never removed. packages/cli/src/utils/sandbox.ts: Fixed leaks in the imageExists and pullImage helper functions where listeners on spawned child processes were not being removed. packages/core/src/tools/grep.ts: Fixed three separate leaks in the isCommandAvailable check and the git grep and system grep strategies due to un-removed listeners on child processes. packages/core/src/tools/tool-registry.ts: Corrected a leak in the execute method of the DiscoveredTool class where listeners on the spawned tool process were not being removed.
2025-06-05 06:40:33 -07:00
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,
};
}
}