2025-04-24 18:03:33 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-08-25 22:11:27 +02:00
import fs from 'node:fs' ;
import path from 'node:path' ;
import os , { EOL } from 'node:os' ;
import crypto from 'node:crypto' ;
2025-08-26 00:04:53 +02:00
import type { Config } from '../config/config.js' ;
2025-08-26 15:26:16 -04:00
import { ToolErrorType } from './tool-error.js' ;
2025-08-26 00:04:53 +02:00
import type {
2025-08-13 12:27:09 -07:00
ToolInvocation ,
2025-04-25 14:05:58 -07:00
ToolResult ,
ToolCallConfirmationDetails ,
ToolExecuteConfirmationDetails ,
2025-08-26 00:04:53 +02:00
} from './tools.js' ;
import {
BaseDeclarativeTool ,
BaseToolInvocation ,
2025-04-25 14:05:58 -07:00
ToolConfirmationOutcome ,
2025-08-13 12:58:26 -03:00
Kind ,
2025-04-25 14:05:58 -07:00
} from './tools.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' ;
2025-09-11 13:27:27 -07:00
import type {
ShellExecutionConfig ,
ShellOutputEvent ,
} from '../services/shellExecutionService.js' ;
2025-08-26 00:04:53 +02:00
import { ShellExecutionService } from '../services/shellExecutionService.js' ;
2025-07-25 21:56:49 -04:00
import { formatMemoryUsage } from '../utils/formatters.js' ;
2025-09-11 13:27:27 -07:00
import type { AnsiOutput } from '../utils/terminalSerializer.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-09-11 13:27:27 -07:00
export class ShellToolInvocation extends BaseToolInvocation <
2025-08-13 12:27:09 -07:00
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-09-02 18:09:28 -06:00
return false ; // already approved and allowlisted
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 ,
2025-09-11 13:27:27 -07:00
updateOutput ? : ( output : string | AnsiOutput ) = > void ,
shellExecutionConfig? : ShellExecutionConfig ,
setPidCallback ? : ( pid : number ) = > 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-09-13 10:33:12 -07:00
const cwd = this . params . directory || this . config . getTargetDir ( ) ;
2025-05-30 01:58:09 -07:00
2025-09-11 13:27:27 -07:00
let cumulativeOutput : string | AnsiOutput = '' ;
2025-07-25 21:56:49 -04:00
let lastUpdateTime = Date . now ( ) ;
let isBinaryStream = false ;
2025-04-27 18:57:10 -07:00
2025-09-11 13:27:27 -07:00
const { result : resultPromise , pid } =
await ShellExecutionService . execute (
commandToExecute ,
cwd ,
( event : ShellOutputEvent ) = > {
if ( ! updateOutput ) {
return ;
}
2025-04-27 18:57:10 -07:00
2025-09-11 13:27:27 -07:00
let shouldUpdate = false ;
switch ( event . type ) {
case 'data' :
if ( isBinaryStream ) break ;
cumulativeOutput = event . chunk ;
2025-07-25 21:56:49 -04:00
shouldUpdate = true ;
2025-09-11 13:27:27 -07:00
break ;
case 'binary_detected' :
isBinaryStream = true ;
cumulativeOutput =
'[Binary output detected. Halting stream...]' ;
2025-07-25 21:56:49 -04:00
shouldUpdate = true ;
2025-09-11 13:27:27 -07:00
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-06-09 12:19:42 -07:00
}
}
2025-04-27 18:57:10 -07:00
2025-09-11 13:27:27 -07:00
if ( shouldUpdate ) {
updateOutput ( cumulativeOutput ) ;
lastUpdateTime = Date . now ( ) ;
}
} ,
signal ,
this . config . getShouldUseNodePtyShell ( ) ,
shellExecutionConfig ? ? { } ,
) ;
if ( pid && setPidCallback ) {
setPidCallback ( pid ) ;
}
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' )
2025-08-22 14:10:45 +08:00
. split ( EOL )
2025-07-25 21:56:49 -04:00
. 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-19 16:03:51 -07:00
llmContent += ` Below is the output 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-19 16:03:51 -07:00
` Output: ${ result . output || '(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-26 15:26:16 -04:00
const executionError = result . error
? {
error : {
message : result.error.message ,
type : ToolErrorType . SHELL_EXECUTE_ERROR ,
} ,
}
: { } ;
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-08-26 15:26:16 -04:00
. . . executionError ,
2025-07-25 21:56:49 -04:00
} ;
}
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-08-26 15:26:16 -04:00
. . . executionError ,
2025-07-15 10:22:31 -07:00
} ;
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
2025-08-15 12:08:29 -07:00
function getShellToolDescription ( ) : string {
const returnedInfo = `
2025-08-13 12:27:09 -07:00
The following information is returned:
Command: Executed command.
2025-09-15 13:27:19 -07:00
Directory: Directory where command was executed, or \` (root) \` .
2025-08-13 12:27:09 -07:00
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) \` .
2025-08-15 12:08:29 -07:00
Process Group PGID: Process group started or \` (none) \` ` ;
if ( os . platform ( ) === 'win32' ) {
return ` This tool executes a given shell command as \` cmd.exe /c <command> \` . Command can start background processes using \` start /b \` . ${ 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 `cmd.exe /c <command>`' ;
} else {
return 'Exact bash command to execute as `bash -c <command>`' ;
}
}
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' ,
getShellToolDescription ( ) ,
2025-08-13 12:27:09 -07:00
Kind . Execute ,
{
type : 'object' ,
properties : {
command : {
type : 'string' ,
2025-08-15 12:08:29 -07:00
description : getCommandDescription ( ) ,
2025-08-13 12:27:09 -07:00
} ,
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 :
2025-09-13 10:33:12 -07:00
'(OPTIONAL) The absolute 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.' ,
2025-08-13 12:27:09 -07:00
} ,
} ,
required : [ 'command' ] ,
} ,
false , // output is not markdown
true , // output can be updated
) ;
}
2025-08-19 13:55:06 -07:00
protected override validateToolParamValues (
2025-08-13 16:17:38 -04:00
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 ;
}
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 ) {
2025-09-13 10:33:12 -07:00
if ( ! path . isAbsolute ( params . directory ) ) {
return 'Directory must be an absolute path.' ;
2025-08-13 12:27:09 -07:00
}
const workspaceDirs = this . config . getWorkspaceContext ( ) . getDirectories ( ) ;
2025-09-13 10:33:12 -07:00
const isWithinWorkspace = workspaceDirs . some ( ( wsDir ) = >
params . directory ! . startsWith ( wsDir ) ,
2025-08-13 12:27:09 -07:00
) ;
2025-09-13 10:33:12 -07:00
if ( ! isWithinWorkspace ) {
return ` Directory ' ${ params . directory } ' is not within any of the registered workspace directories. ` ;
2025-08-13 12:27:09 -07:00
}
}
return null ;
}
protected createInvocation (
params : ShellToolParams ,
) : ToolInvocation < ShellToolParams , ToolResult > {
return new ShellToolInvocation ( this . config , params , this . allowlist ) ;
}
}