2025-04-24 18:03:33 -07:00
/**
* @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' ;
2025-05-06 10:44:40 -07:00
import os from 'os' ;
import crypto from 'crypto' ;
2025-04-24 18:03:33 -07:00
import { Config } from '../config/config.js' ;
2025-04-25 14:05:58 -07:00
import {
2025-08-13 12:27:09 -07:00
BaseDeclarativeTool ,
BaseToolInvocation ,
ToolInvocation ,
2025-04-25 14:05:58 -07:00
ToolResult ,
ToolCallConfirmationDetails ,
ToolExecuteConfirmationDetails ,
ToolConfirmationOutcome ,
2025-08-13 12:58:26 -03:00
Kind ,
2025-04-25 14:05:58 -07:00
} from './tools.js' ;
2025-04-27 18:57:10 -07:00
import { SchemaValidator } from '../utils/schemaValidator.js' ;
2025-05-18 00:23:57 -07:00
import { getErrorMessage } from '../utils/errors.js' ;
2025-07-25 21:56:49 -04:00
import { summarizeToolOutput } from '../utils/summarizer.js' ;
import {
ShellExecutionService ,
ShellOutputEvent ,
} from '../services/shellExecutionService.js' ;
import { formatMemoryUsage } from '../utils/formatters.js' ;
2025-07-25 12:25:32 -07:00
import {
getCommandRoots ,
isCommandAllowed ,
stripShellWrapper ,
} from '../utils/shell-utils.js' ;
2025-06-02 14:50:12 -07:00
2025-07-25 21:56:49 -04:00
export const OUTPUT_UPDATE_INTERVAL_MS = 1000 ;
2025-04-24 18:03:33 -07:00
export interface ShellToolParams {
command : string ;
description? : string ;
2025-04-27 18:57:10 -07:00
directory? : string ;
2025-04-24 18:03:33 -07:00
}
2025-05-30 01:58:09 -07:00
2025-08-13 12:27:09 -07:00
class ShellToolInvocation extends BaseToolInvocation <
ShellToolParams ,
ToolResult
> {
constructor (
private readonly config : Config ,
params : ShellToolParams ,
private readonly allowlist : Set < string > ,
) {
super ( params ) ;
2025-04-25 14:05:58 -07:00
}
2025-08-13 12:27:09 -07:00
getDescription ( ) : string {
let description = ` ${ this . params . command } ` ;
2025-04-30 12:27:56 -07:00
// append optional [in directory]
2025-04-29 15:31:46 -07:00
// note description is needed even if validation fails due to absolute path
2025-08-13 12:27:09 -07:00
if ( this . params . directory ) {
description += ` [in ${ this . params . directory } ] ` ;
2025-04-28 08:17:52 -07:00
}
// append optional (description), replacing any line breaks with spaces
2025-08-13 12:27:09 -07:00
if ( this . params . description ) {
description += ` ( ${ this . params . description . replace ( /\n/g , ' ' ) } ) ` ;
2025-04-27 18:57:10 -07:00
}
return description ;
2025-04-25 14:05:58 -07:00
}
2025-08-13 16:17:38 -04:00
override async shouldConfirmExecute (
2025-05-27 23:40:25 -07:00
_abortSignal : AbortSignal ,
2025-04-25 14:05:58 -07:00
) : Promise < ToolCallConfirmationDetails | false > {
2025-08-13 12:27:09 -07:00
const command = stripShellWrapper ( this . params . command ) ;
2025-07-25 12:25:32 -07:00
const rootCommands = [ . . . new Set ( getCommandRoots ( command ) ) ] ;
const commandsToConfirm = rootCommands . filter (
( command ) = > ! this . allowlist . has ( command ) ,
) ;
if ( commandsToConfirm . length === 0 ) {
2025-04-27 18:57:10 -07:00
return false ; // already approved and whitelisted
2025-04-25 14:05:58 -07:00
}
2025-07-25 12:25:32 -07:00
2025-04-25 14:05:58 -07:00
const confirmationDetails : ToolExecuteConfirmationDetails = {
2025-05-22 06:00:36 +00:00
type : 'exec' ,
2025-04-25 14:05:58 -07:00
title : 'Confirm Shell Command' ,
2025-08-13 12:27:09 -07:00
command : this.params.command ,
2025-07-25 12:25:32 -07:00
rootCommand : commandsToConfirm.join ( ', ' ) ,
2025-04-25 14:05:58 -07:00
onConfirm : async ( outcome : ToolConfirmationOutcome ) = > {
if ( outcome === ToolConfirmationOutcome . ProceedAlways ) {
2025-07-25 12:25:32 -07:00
commandsToConfirm . forEach ( ( command ) = > this . allowlist . add ( command ) ) ;
2025-04-25 14:05:58 -07:00
}
} ,
} ;
return confirmationDetails ;
2025-04-24 18:03:33 -07:00
}
2025-05-09 23:29:02 -07:00
async execute (
2025-07-25 12:25:32 -07:00
signal : AbortSignal ,
updateOutput ? : ( output : string ) = > void ,
2025-05-09 23:29:02 -07:00
) : Promise < ToolResult > {
2025-08-13 12:27:09 -07:00
const strippedCommand = stripShellWrapper ( this . params . command ) ;
2025-04-27 18:57:10 -07:00
2025-07-25 12:25:32 -07:00
if ( signal . aborted ) {
2025-06-08 15:42:49 -07:00
return {
llmContent : 'Command was cancelled by user before it could start.' ,
returnDisplay : 'Command cancelled by user.' ,
} ;
}
2025-06-09 12:19:42 -07:00
const isWindows = os . platform ( ) === 'win32' ;
2025-06-15 02:19:19 -07:00
const tempFileName = ` shell_pgrep_ ${ crypto
. randomBytes ( 6 )
. toString ( 'hex' ) } .tmp ` ;
const tempFilePath = path . join ( os . tmpdir ( ) , tempFileName ) ;
2025-05-06 10:44:40 -07:00
2025-07-25 21:56:49 -04:00
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-07-25 21:56:49 -04:00
const cwd = path . resolve (
this . config . getTargetDir ( ) ,
2025-08-13 12:27:09 -07:00
this . params . directory || '' ,
2025-07-25 21:56:49 -04:00
) ;
2025-05-30 01:58:09 -07:00
2025-08-15 10:27:33 -07:00
let cumulativeStdout = '' ;
let cumulativeStderr = '' ;
2025-07-25 21:56:49 -04:00
let lastUpdateTime = Date . now ( ) ;
let isBinaryStream = false ;
2025-04-27 18:57:10 -07:00
2025-07-25 21:56:49 -04:00
const { result : resultPromise } = ShellExecutionService . execute (
commandToExecute ,
cwd ,
( event : ShellOutputEvent ) = > {
if ( ! updateOutput ) {
return ;
}
2025-04-27 18:57:10 -07:00
2025-07-25 21:56:49 -04:00
let currentDisplayOutput = '' ;
let shouldUpdate = false ;
2025-05-09 23:29:02 -07:00
2025-07-25 21:56:49 -04:00
switch ( event . type ) {
case 'data' :
2025-08-15 10:27:33 -07:00
if ( isBinaryStream ) break ; // Don't process text if we are in binary mode
if ( event . stream === 'stdout' ) {
cumulativeStdout += event . chunk ;
} else {
cumulativeStderr += event . chunk ;
}
currentDisplayOutput =
cumulativeStdout +
( cumulativeStderr ? ` \ n ${ cumulativeStderr } ` : '' ) ;
2025-07-25 21:56:49 -04:00
if ( Date . now ( ) - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS ) {
shouldUpdate = true ;
}
break ;
case 'binary_detected' :
isBinaryStream = true ;
currentDisplayOutput =
'[Binary output detected. Halting stream...]' ;
shouldUpdate = true ;
break ;
case 'binary_progress' :
isBinaryStream = true ;
currentDisplayOutput = ` [Receiving binary output... ${ formatMemoryUsage (
event . bytesReceived ,
) } received] ` ;
if ( Date . now ( ) - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS ) {
shouldUpdate = true ;
2025-06-09 12:19:42 -07:00
}
2025-07-25 21:56:49 -04:00
break ;
default : {
throw new Error ( 'An unhandled ShellOutputEvent was found.' ) ;
2025-06-09 12:19:42 -07:00
}
2025-05-09 23:29:02 -07:00
}
2025-04-27 18:57:10 -07:00
2025-07-25 21:56:49 -04:00
if ( shouldUpdate ) {
updateOutput ( currentDisplayOutput ) ;
lastUpdateTime = Date . now ( ) ;
}
} ,
signal ,
) ;
2025-05-09 23:29:02 -07:00
2025-07-25 21:56:49 -04:00
const result = await resultPromise ;
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 ) ;
if ( pid !== result . pid ) {
backgroundPIDs . push ( pid ) ;
}
2025-06-09 12:19:42 -07:00
}
2025-07-25 21:56:49 -04:00
} else {
if ( ! signal . aborted ) {
console . error ( 'missing pgrep output' ) ;
2025-06-09 12:19:42 -07:00
}
2025-05-06 10:44:40 -07:00
}
}
2025-07-25 21:56:49 -04:00
let llmContent = '' ;
if ( result . aborted ) {
llmContent = 'Command was cancelled by user before it could complete.' ;
if ( result . output . trim ( ) ) {
2025-08-15 10:27:33 -07:00
llmContent += ` Below is the output (on stdout and stderr) before it was cancelled: \ n ${ result . output } ` ;
2025-07-25 21:56:49 -04:00
} else {
llmContent += ' There was no output before it was cancelled.' ;
}
2025-05-27 13:47:40 -07:00
} else {
2025-07-25 21:56:49 -04:00
// Create a formatted error string for display, replacing the wrapper command
// with the user-facing command.
const finalError = result . error
2025-08-13 12:27:09 -07:00
? result . error . message . replace ( commandToExecute , this . params . command )
2025-07-25 21:56:49 -04:00
: '(none)' ;
llmContent = [
2025-08-13 12:27:09 -07:00
` Command: ${ this . params . command } ` ,
` Directory: ${ this . params . directory || '(root)' } ` ,
2025-08-15 10:27:33 -07:00
` Stdout: ${ result . stdout || '(empty)' } ` ,
` Stderr: ${ result . stderr || '(empty)' } ` ,
2025-07-25 21:56:49 -04:00
` 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' ) ;
2025-05-27 13:47:40 -07:00
}
2025-04-28 15:05:36 -07:00
2025-07-25 21:56:49 -04:00
let returnDisplayMessage = '' ;
if ( this . config . getDebugMode ( ) ) {
returnDisplayMessage = llmContent ;
2025-05-18 00:23:57 -07:00
} else {
2025-07-25 21:56:49 -04:00
if ( result . output . trim ( ) ) {
returnDisplayMessage = result . output ;
} else {
if ( result . aborted ) {
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.
2025-05-18 00:23:57 -07:00
}
}
2025-07-12 21:09:12 -07:00
2025-07-25 21:56:49 -04:00
const summarizeConfig = this . config . getSummarizeToolOutputConfig ( ) ;
2025-08-13 12:27:09 -07:00
if ( summarizeConfig && summarizeConfig [ ShellTool . Name ] ) {
2025-07-25 21:56:49 -04:00
const summary = await summarizeToolOutput (
llmContent ,
this . config . getGeminiClient ( ) ,
signal ,
2025-08-13 12:27:09 -07:00
summarizeConfig [ ShellTool . Name ] . tokenBudget ,
2025-07-25 21:56:49 -04:00
) ;
return {
llmContent : summary ,
returnDisplay : returnDisplayMessage ,
} ;
}
2025-07-15 10:22:31 -07:00
return {
2025-07-25 21:56:49 -04:00
llmContent ,
2025-07-15 10:22:31 -07:00
returnDisplay : returnDisplayMessage ,
} ;
2025-07-25 21:56:49 -04:00
} finally {
if ( fs . existsSync ( tempFilePath ) ) {
fs . unlinkSync ( tempFilePath ) ;
}
2025-07-15 10:22:31 -07:00
}
2025-04-24 18:03:33 -07:00
}
}
2025-08-13 12:27:09 -07:00
export class ShellTool extends BaseDeclarativeTool <
ShellToolParams ,
ToolResult
> {
static Name : string = 'run_shell_command' ;
private allowlist : 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) \` ` ,
Kind . Execute ,
{
type : 'object' ,
properties : {
command : {
type : 'string' ,
description : 'Exact bash command to execute as `bash -c <command>`' ,
} ,
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.' ,
} ,
directory : {
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
) ;
}
2025-08-13 16:17:38 -04:00
protected override validateToolParams (
params : ShellToolParams ,
) : string | null {
2025-08-13 12:27:09 -07:00
const commandCheck = isCommandAllowed ( params . command , this . config ) ;
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 . parametersJsonSchema ,
params ,
) ;
if ( errors ) {
return errors ;
}
if ( ! params . command . trim ( ) ) {
return 'Command cannot be empty.' ;
}
if ( getCommandRoots ( params . command ) . length === 0 ) {
return 'Could not identify command root to obtain permission from user.' ;
}
if ( params . directory ) {
if ( path . isAbsolute ( params . directory ) ) {
return 'Directory cannot be absolute. Please refer to workspace directories by their name.' ;
}
const workspaceDirs = this . config . getWorkspaceContext ( ) . getDirectories ( ) ;
const matchingDirs = workspaceDirs . filter (
( dir ) = > path . basename ( dir ) === params . directory ,
) ;
if ( matchingDirs . length === 0 ) {
return ` Directory ' ${ params . directory } ' is not a registered workspace directory. ` ;
}
if ( matchingDirs . length > 1 ) {
return ` Directory name ' ${ params . directory } ' is ambiguous as it matches multiple workspace directories. ` ;
}
}
return null ;
}
protected createInvocation (
params : ShellToolParams ,
) : ToolInvocation < ShellToolParams , ToolResult > {
return new ShellToolInvocation ( this . config , params , this . allowlist ) ;
}
}