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

494 lines
16 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import os, { EOL } from 'node:os';
import crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { debugLogger } from '../index.js';
import { ToolErrorType } from './tool-error.js';
import type {
ToolInvocation,
ToolResult,
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
} from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
ToolConfirmationOutcome,
Kind,
type PolicyUpdateOptions,
} from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import type {
ShellExecutionConfig,
ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import {
getCommandRoots,
initializeShellParsers,
stripShellWrapper,
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
2025-06-02 14:50:12 -07:00
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
export interface ShellToolParams {
command: string;
description?: string;
2025-11-06 15:03:52 -08:00
dir_path?: string;
}
export class ShellToolInvocation extends BaseToolInvocation<
ShellToolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: ShellToolParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
) {
super(params, messageBus, _toolName, _toolDisplayName);
}
getDescription(): string {
let description = `${this.params.command}`;
// append optional [in directory]
// note description is needed even if validation fails due to absolute path
2025-11-06 15:03:52 -08:00
if (this.params.dir_path) {
description += ` [in ${this.params.dir_path}]`;
} else {
description += ` [current working directory ${process.cwd()}]`;
2025-04-28 08:17:52 -07:00
}
// append optional (description), replacing any line breaks with spaces
if (this.params.description) {
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
2025-04-27 18:57:10 -07:00
}
return description;
}
protected override getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
outcome === ToolConfirmationOutcome.ProceedAlways
) {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
if (rootCommands.length > 0) {
return { commandPrefix: rootCommands };
}
return { commandPrefix: this.params.command };
}
return undefined;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
const command = stripShellWrapper(this.params.command);
let rootCommands = [...new Set(getCommandRoots(command))];
// Fallback for UI display if parser fails or returns no commands (e.g.
// variable assignments only)
if (rootCommands.length === 0 && command.trim()) {
const fallback = command.trim().split(/\s+/)[0];
if (fallback) {
rootCommands = [fallback];
}
}
// Rely entirely on PolicyEngine for interactive confirmation.
// If we are here, it means PolicyEngine returned ASK_USER (or no message bus),
// so we must provide confirmation details.
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
command: this.params.command,
rootCommand: rootCommands.join(', '),
onConfirm: async (outcome: ToolConfirmationOutcome) => {
await this.publishPolicyUpdate(outcome);
},
};
return confirmationDetails;
}
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
shellExecutionConfig?: ShellExecutionConfig,
setPidCallback?: (pid: number) => void,
): Promise<ToolResult> {
const strippedCommand = stripShellWrapper(this.params.command);
2025-04-27 18:57:10 -07:00
if (signal.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);
const timeoutMs = this.config.getShellToolInactivityTimeout();
const timeoutController = new AbortController();
let timeoutTimer: NodeJS.Timeout | undefined;
// Handle signal combination manually to avoid TS issues or runtime missing features
const combinedController = new AbortController();
const onAbort = () => combinedController.abort();
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
? strippedCommand
: (() => {
// wrap command to append subprocess pids (via pgrep) to temporary file
let command = strippedCommand.trim();
if (!command.endsWith('&')) command += ';';
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
})();
2025-04-27 18:57:10 -07:00
2025-11-06 15:03:52 -08:00
const cwd = this.params.dir_path
? path.resolve(this.config.getTargetDir(), this.params.dir_path)
: this.config.getTargetDir();
let cumulativeOutput: string | AnsiOutput = '';
let lastUpdateTime = Date.now();
let isBinaryStream = false;
2025-04-27 18:57:10 -07:00
const resetTimeout = () => {
if (timeoutMs <= 0) {
return;
}
if (timeoutTimer) clearTimeout(timeoutTimer);
timeoutTimer = setTimeout(() => {
timeoutController.abort();
}, timeoutMs);
};
signal.addEventListener('abort', onAbort, { once: true });
timeoutController.signal.addEventListener('abort', onAbort, {
once: true,
});
// Start timeout
resetTimeout();
const { result: resultPromise, pid } =
await ShellExecutionService.execute(
commandToExecute,
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
2025-04-27 18:57:10 -07:00
let shouldUpdate = false;
switch (event.type) {
case 'data':
if (isBinaryStream) break;
cumulativeOutput = event.chunk;
shouldUpdate = true;
break;
case 'binary_detected':
isBinaryStream = true;
cumulativeOutput =
'[Binary output detected. Halting stream...]';
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage(
event.bytesReceived,
)} received]`;
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
shouldUpdate = true;
}
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
2025-04-27 18:57:10 -07:00
if (shouldUpdate) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
},
combinedController.signal,
this.config.getEnableInteractiveShell(),
{
...shellExecutionConfig,
pager: 'cat',
sanitizationConfig:
shellExecutionConfig?.sanitizationConfig ??
this.config.sanitizationConfig,
},
);
if (pid && setPidCallback) {
setPidCallback(pid);
}
const result = await resultPromise;
const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') {
if (fs.existsSync(tempFilePath)) {
const pgrepLines = fs
.readFileSync(tempFilePath, 'utf8')
.split(EOL)
.filter(Boolean);
for (const line of pgrepLines) {
if (!/^\d+$/.test(line)) {
debugLogger.error(`pgrep: ${line}`);
}
const pid = Number(line);
if (pid !== result.pid) {
backgroundPIDs.push(pid);
}
}
} else {
if (!signal.aborted) {
debugLogger.error('missing pgrep output');
}
}
}
let llmContent = '';
let timeoutMessage = '';
if (result.aborted) {
if (timeoutController.signal.aborted) {
timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${(
timeoutMs / 60000
).toFixed(1)} minutes without output.`;
llmContent = timeoutMessage;
} else {
llmContent =
'Command was cancelled by user before it could complete.';
}
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
} else {
llmContent += ' There was no output before it was cancelled.';
}
} else {
// Create a formatted error string for display, replacing the wrapper command
// with the user-facing command.
const finalError = result.error
? result.error.message.replace(commandToExecute, this.params.command)
: '(none)';
llmContent = [
`Command: ${this.params.command}`,
2025-11-06 15:03:52 -08:00
`Directory: ${this.params.dir_path || '(root)'}`,
`Output: ${result.output || '(empty)'}`,
`Error: ${finalError}`, // Use the cleaned error string.
`Exit Code: ${result.exitCode ?? '(none)'}`,
`Signal: ${result.signal ?? '(none)'}`,
`Background PIDs: ${
backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'
}`,
`Process Group PGID: ${result.pid ?? '(none)'}`,
].join('\n');
}
let returnDisplayMessage = '';
if (this.config.getDebugMode()) {
returnDisplayMessage = llmContent;
} else {
if (result.output.trim()) {
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
if (timeoutMessage) {
returnDisplayMessage = timeoutMessage;
} else {
returnDisplayMessage = 'Command cancelled by user.';
}
} else if (result.signal) {
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
returnDisplayMessage = `Command failed: ${getErrorMessage(
result.error,
)}`;
} else if (result.exitCode !== null && result.exitCode !== 0) {
returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
}
// If output is empty and command succeeded (code 0, no error/signal/abort),
// returnDisplayMessage will remain empty, which is fine.
}
}
const summarizeConfig = this.config.getSummarizeToolOutputConfig();
const executionError = result.error
? {
error: {
message: result.error.message,
type: ToolErrorType.SHELL_EXECUTE_ERROR,
},
}
: {};
if (summarizeConfig && summarizeConfig[SHELL_TOOL_NAME]) {
const summary = await summarizeToolOutput(
this.config,
{ model: 'summarizer-shell' },
llmContent,
this.config.getGeminiClient(),
signal,
);
return {
llmContent: summary,
returnDisplay: returnDisplayMessage,
...executionError,
};
}
return {
llmContent,
returnDisplay: returnDisplayMessage,
...executionError,
};
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
}
}
}
function getShellToolDescription(): string {
const returnedInfo = `
The following information is returned:
Command: Executed command.
2025-09-15 13:27:19 -07:00
Directory: Directory 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)\``;
if (os.platform() === 'win32') {
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.${returnedInfo}`;
} else {
return `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\`.${returnedInfo}`;
}
}
function getCommandDescription(): string {
if (os.platform() === 'win32') {
return 'Exact command to execute as `powershell.exe -NoProfile -Command <command>`';
} else {
return 'Exact bash command to execute as `bash -c <command>`';
}
}
export class ShellTool extends BaseDeclarativeTool<
ShellToolParams,
ToolResult
> {
static readonly Name = SHELL_TOOL_NAME;
constructor(
private readonly config: Config,
messageBus: MessageBus,
) {
void initializeShellParsers().catch(() => {
// Errors are surfaced when parsing commands.
});
super(
ShellTool.Name,
'Shell',
getShellToolDescription(),
Kind.Execute,
{
type: 'object',
properties: {
command: {
type: 'string',
description: getCommandDescription(),
},
description: {
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.',
},
2025-11-06 15:03:52 -08:00
dir_path: {
type: 'string',
description:
2025-11-06 15:03:52 -08:00
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
},
},
required: ['command'],
},
messageBus,
false, // output is not markdown
true, // output can be updated
);
}
protected override validateToolParamValues(
params: ShellToolParams,
): string | null {
if (!params.command.trim()) {
return 'Command cannot be empty.';
}
2025-11-06 15:03:52 -08:00
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.dir_path,
);
2025-11-06 15:03:52 -08:00
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(resolvedPath)) {
return `Directory '${resolvedPath}' is not within any of the registered workspace directories.`;
}
}
return null;
}
protected createInvocation(
params: ShellToolParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
): ToolInvocation<ShellToolParams, ToolResult> {
return new ShellToolInvocation(
this.config,
params,
messageBus,
_toolName,
_toolDisplayName,
);
}
}