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 {
BaseTool ,
ToolResult ,
ToolCallConfirmationDetails ,
ToolExecuteConfirmationDetails ,
ToolConfirmationOutcome ,
} from './tools.js' ;
2025-07-07 23:48:44 -07:00
import { Type } from '@google/genai' ;
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-06-02 14:50:12 -07:00
import stripAnsi from 'strip-ansi' ;
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-04-27 18:57:10 -07:00
import { spawn } from 'child_process' ;
2025-07-11 09:29:08 -07:00
import { llmSummarizer } from '../utils/summarizer.js' ;
2025-04-24 18:03:33 -07:00
2025-05-30 01:58:09 -07:00
const OUTPUT_UPDATE_INTERVAL_MS = 1000 ;
2025-04-24 18:03:33 -07:00
export class ShellTool extends BaseTool < ShellToolParams , ToolResult > {
2025-06-09 08:57:30 -07:00
static Name : string = 'run_shell_command' ;
2025-04-25 14:05:58 -07:00
private whitelist : Set < string > = new Set ( ) ;
2025-04-24 18:03:33 -07:00
2025-05-02 09:31:18 -07:00
constructor ( private readonly config : Config ) {
2025-06-28 02:53:03 -07:00
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 \` .
2025-06-03 21:40:46 -07:00
2025-06-28 02:53:03 -07:00
The following information is returned:
2025-06-03 21:40:46 -07:00
2025-06-28 02:53:03 -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) \` ` ,
{
2025-07-07 23:48:44 -07:00
type : Type . OBJECT ,
2025-06-03 21:40:46 -07:00
properties : {
2025-06-28 02:53:03 -07:00
command : {
2025-07-07 23:48:44 -07:00
type : Type . STRING ,
2025-06-28 02:53:03 -07:00
description : 'Exact bash command to execute as `bash -c <command>`' ,
} ,
description : {
2025-07-07 23:48:44 -07:00
type : Type . STRING ,
2025-06-28 02:53:03 -07:00
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 : {
2025-07-07 23:48:44 -07:00
type : Type . STRING ,
2025-06-28 02:53:03 -07:00
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.' ,
} ,
2025-06-03 21:40:46 -07:00
} ,
required : [ 'command' ] ,
2025-06-28 02:53:03 -07:00
} ,
2025-05-30 13:59:05 -07:00
false , // output is not markdown
true , // output can be updated
2025-07-11 09:29:08 -07:00
llmSummarizer ,
true , // should summarize display output
2025-04-24 18:03:33 -07:00
) ;
2025-04-25 14:05:58 -07:00
}
getDescription ( params : ShellToolParams ) : string {
2025-04-27 18:57:10 -07:00
let description = ` ${ 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-04-28 08:17:52 -07:00
if ( params . directory ) {
2025-04-30 12:27:56 -07:00
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 ;
2025-04-25 14:05:58 -07:00
}
2025-07-04 09:13:02 +09:00
/**
* 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)
}
2025-07-04 09:13:02 +09:00
/**
* 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
2025-07-07 14:03:36 +09:00
* @returns An object with 'allowed' boolean and optional 'reason' string if not allowed
2025-07-04 09:13:02 +09:00
*/
2025-07-07 14:03:36 +09:00
isCommandAllowed ( command : string ) : { allowed : boolean ; reason? : string } {
2025-06-30 11:42:35 -04:00
// 0. Disallow command substitution
2025-07-07 14:03:36 +09:00
if ( command . includes ( '$(' ) ) {
return {
allowed : false ,
reason :
'Command substitution using $() is not allowed for security reasons' ,
} ;
}
2025-06-30 11:42:35 -04:00
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 ] === ' ' ;
} ;
2025-06-29 15:32:26 -04:00
2025-06-30 11:42:35 -04:00
/**
* Extracts and normalizes shell commands from a list of tool strings.
* e.g., 'ShellTool("ls -l")' becomes 'ls -l'
*/
2025-06-29 15:32:26 -04:00
const extractCommands = ( tools : string [ ] ) : string [ ] = >
tools . flatMap ( ( tool ) = > {
2025-06-30 11:42:35 -04:00
for ( const toolName of SHELL_TOOL_NAMES ) {
if ( tool . startsWith ( ` ${ toolName } ( ` ) && tool . endsWith ( ')' ) ) {
return [ normalize ( tool . slice ( toolName . length + 1 , - 1 ) ) ] ;
}
2025-06-29 15:32:26 -04:00
}
return [ ] ;
} ) ;
const coreTools = this . config . getCoreTools ( ) || [ ] ;
const excludeTools = this . config . getExcludeTools ( ) || [ ] ;
2025-06-30 11:42:35 -04:00
// 1. Check if the shell tool is globally disabled.
if ( SHELL_TOOL_NAMES . some ( ( name ) = > excludeTools . includes ( name ) ) ) {
2025-07-07 14:03:36 +09:00
return {
allowed : false ,
reason : 'Shell tool is globally disabled in configuration' ,
} ;
2025-06-29 15:32:26 -04:00
}
2025-06-30 11:42:35 -04:00
const blockedCommands = new Set ( extractCommands ( excludeTools ) ) ;
const allowedCommands = new Set ( extractCommands ( coreTools ) ) ;
2025-06-29 15:32:26 -04:00
2025-06-30 11:42:35 -04:00
const hasSpecificAllowedCommands = allowedCommands . size > 0 ;
const isWildcardAllowed = SHELL_TOOL_NAMES . some ( ( name ) = >
coreTools . includes ( name ) ,
2025-06-29 15:32:26 -04:00
) ;
2025-06-30 11:42:35 -04:00
const commandsToValidate = command . split ( /&&|\|\||\||;/ ) . map ( normalize ) ;
2025-07-07 14:03:36 +09:00
const blockedCommandsArr = [ . . . blockedCommands ] ;
2025-06-30 11:42:35 -04:00
for ( const cmd of commandsToValidate ) {
// 2. Check if the command is on the blocklist.
2025-07-07 14:03:36 +09:00
const isBlocked = blockedCommandsArr . some ( ( blocked ) = >
2025-06-30 11:42:35 -04:00
isPrefixedBy ( cmd , blocked ) ,
) ;
if ( isBlocked ) {
2025-07-07 14:03:36 +09:00
return {
allowed : false ,
reason : ` Command ' ${ cmd } ' is blocked by configuration ` ,
} ;
2025-06-29 15:32:26 -04:00
}
2025-06-30 11:42:35 -04:00
// 3. If in strict allow-list mode, check if the command is permitted.
const isStrictAllowlist =
hasSpecificAllowedCommands && ! isWildcardAllowed ;
2025-07-07 14:03:36 +09:00
const allowedCommandsArr = [ . . . allowedCommands ] ;
2025-06-30 11:42:35 -04:00
if ( isStrictAllowlist ) {
2025-07-07 14:03:36 +09:00
const isAllowed = allowedCommandsArr . some ( ( allowed ) = >
2025-06-30 11:42:35 -04:00
isPrefixedBy ( cmd , allowed ) ,
) ;
if ( ! isAllowed ) {
2025-07-07 14:03:36 +09:00
return {
allowed : false ,
reason : ` Command ' ${ cmd } ' is not in the allowed commands list ` ,
} ;
2025-06-30 11:42:35 -04:00
}
}
2025-06-29 15:32:26 -04:00
}
2025-06-30 11:42:35 -04:00
// 4. If all checks pass, the command is allowed.
2025-07-07 14:03:36 +09:00
return { allowed : true } ;
2025-06-29 15:32:26 -04:00
}
2025-04-27 18:57:10 -07:00
validateToolParams ( params : ShellToolParams ) : string | null {
2025-07-07 14:03:36 +09:00
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 ;
2025-06-29 15:32:26 -04:00
}
2025-07-07 23:48:44 -07:00
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.' ;
}
}
2025-04-25 14:05:58 -07:00
return null ;
}
async shouldConfirmExecute (
params : ShellToolParams ,
2025-05-27 23:40:25 -07:00
_abortSignal : AbortSignal ,
2025-04-25 14:05:58 -07:00
) : 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
2025-04-25 14:05:58 -07:00
if ( this . whitelist . has ( rootCommand ) ) {
2025-04-27 18:57:10 -07:00
return false ; // already approved and whitelisted
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' ,
command : params.command ,
rootCommand ,
onConfirm : async ( outcome : ToolConfirmationOutcome ) = > {
if ( outcome === ToolConfirmationOutcome . ProceedAlways ) {
this . whitelist . add ( rootCommand ) ;
}
} ,
} ;
return confirmationDetails ;
2025-04-24 18:03:33 -07:00
}
2025-05-09 23:29:02 -07:00
async execute (
params : ShellToolParams ,
abortSignal : AbortSignal ,
2025-05-30 01:58:09 -07:00
updateOutput ? : ( chunk : string ) = > void ,
2025-05-09 23:29:02 -07:00
) : 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 } ` ,
} ;
}
2025-06-08 15:42:49 -07:00
if ( abortSignal . aborted ) {
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-06-09 12:19:42 -07:00
// 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)
2025-06-09 12:19:42 -07:00
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
2025-05-28 14:45:46 -07:00
let exited = false ;
2025-04-27 18:57:10 -07:00
let stdout = '' ;
let output = '' ;
2025-05-30 01:58:09 -07:00
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 ) = > {
2025-05-28 14:45:46 -07:00
// 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 ( ) ) ;
2025-05-28 14:45:46 -07:00
stdout += str ;
2025-05-30 01:58:09 -07:00
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 ) = > {
2025-05-28 14:45:46 -07:00
if ( ! exited ) {
2025-06-02 14:50:12 -07:00
const str = stripAnsi ( data . toString ( ) ) ;
2025-05-28 14:45:46 -07:00
stderr += str ;
2025-05-30 01:58:09 -07:00
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 ;
2025-05-09 23:29:02 -07:00
let processSignal : NodeJS.Signals | null = null ;
2025-05-28 14:45:46 -07:00
const exitHandler = (
2025-05-09 23:29:02 -07:00
_code : number | null ,
_signal : NodeJS.Signals | null ,
) = > {
2025-05-28 14:45:46 -07:00
exited = true ;
2025-05-09 23:29:02 -07:00
code = _code ;
processSignal = _signal ;
} ;
2025-05-28 14:45:46 -07:00
shell . on ( 'exit' , exitHandler ) ;
2025-05-09 23:29:02 -07:00
2025-05-30 01:35:03 -07:00
const abortHandler = async ( ) = > {
2025-05-30 00:46:43 -07:00
if ( shell . pid && ! exited ) {
2025-06-09 12:19:42 -07:00
if ( os . platform ( ) === 'win32' ) {
// For Windows, use taskkill to kill the process tree
spawn ( 'taskkill' , [ '/pid' , shell . pid . toString ( ) , '/f' , '/t' ] ) ;
} else {
2025-05-09 23:29:02 -07:00
try {
2025-06-09 12:19:42 -07:00
// 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' ) ;
2025-05-30 00:46:43 -07:00
}
} catch ( _e ) {
2025-06-09 12:19:42 -07:00
// 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 } ` ) ;
}
2025-05-09 23:29:02 -07:00
}
}
}
} ;
abortSignal . addEventListener ( 'abort' , abortHandler ) ;
2025-04-27 18:57:10 -07:00
// wait for the shell to exit
2025-06-05 06:40:33 -07:00
try {
await new Promise ( ( resolve ) = > shell . on ( 'exit' , resolve ) ) ;
} finally {
abortSignal . removeEventListener ( 'abort' , abortHandler ) ;
}
2025-05-09 23:29:02 -07:00
2025-05-06 10:44:40 -07:00
// parse pids (pgrep output) from temporary file and remove it
const backgroundPIDs : number [ ] = [ ] ;
2025-06-09 12:19:42 -07:00
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 ) ;
}
2025-05-06 10:44:40 -07:00
}
2025-06-09 12:19:42 -07:00
fs . unlinkSync ( tempFilePath ) ;
} else {
if ( ! abortSignal . aborted ) {
console . error ( 'missing pgrep output' ) ;
2025-05-06 10:44:40 -07:00
}
}
}
2025-05-09 23:29:02 -07:00
let llmContent = '' ;
if ( abortSignal . aborted ) {
2025-05-27 13:47:40 -07:00
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.' ;
}
2025-05-09 23:29:02 -07:00
} 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)' } ` ,
2025-05-30 23:25:44 -07:00
` Process Group PGID: ${ shell . pid ? ? '(none)' } ` ,
2025-05-09 23:29:02 -07:00
] . join ( '\n' ) ;
}
2025-04-28 15:05:36 -07:00
2025-05-18 00:23:57 -07:00
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 } ;
2025-04-24 18:03:33 -07:00
}
}