summaryrefslogtreecommitdiff
path: root/packages/core/src/tools/shell.ts
diff options
context:
space:
mode:
authorTommaso Sciortino <[email protected]>2025-05-30 18:25:47 -0700
committerGitHub <[email protected]>2025-05-30 18:25:47 -0700
commit21fba832d1b4ea7af43fb887d9b2b38fcf8210d0 (patch)
tree7200d2fac3a55c385e0a2dac34b5282c942364bc /packages/core/src/tools/shell.ts
parentc81148a0cc8489f657901c2cc7247c0834075e1a (diff)
Rename server->core (#638)
Diffstat (limited to 'packages/core/src/tools/shell.ts')
-rw-r--r--packages/core/src/tools/shell.ts313
1 files changed, 313 insertions, 0 deletions
diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts
new file mode 100644
index 00000000..4efc3500
--- /dev/null
+++ b/packages/core/src/tools/shell.ts
@@ -0,0 +1,313 @@
+/**
+ * @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<ShellToolParams, ToolResult> {
+ static Name: string = 'execute_bash_command';
+ private whitelist: Set<string> = 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<string, unknown>,
+ 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<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}`,
+ };
+ }
+
+ // 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 };
+ }
+}