2025-10-26 19:50:41 +00:00
import z from "zod"
2025-10-12 04:24:48 +00:00
import { spawn } from "child_process"
2025-05-31 18:41:00 +00:00
import { Tool } from "./tool"
2025-06-04 17:12:13 +00:00
import DESCRIPTION from "./bash.txt"
2025-08-01 00:40:05 +00:00
import { Log } from "../util/log"
2025-09-01 21:15:49 +00:00
import { Instance } from "../project/instance"
2025-10-31 19:07:36 +00:00
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { Agent } from "@/agent/agent"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { Wildcard } from "@/util/wildcard"
import { Permission } from "@/permission"
2025-05-19 23:29:38 +00:00
2025-08-12 20:14:40 +00:00
const MAX_OUTPUT_LENGTH = 30 _000
2025-05-31 18:41:00 +00:00
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
2025-10-16 19:39:36 +00:00
const SIGKILL_TIMEOUT_MS = 200
2025-05-19 23:29:38 +00:00
2025-10-31 19:07:36 +00:00
export const log = Log . create ( { service : "bash-tool" } )
2025-08-01 01:41:48 +00:00
2025-10-07 03:24:07 +00:00
const parser = lazy ( async ( ) = > {
2025-10-31 19:07:36 +00:00
const { Parser } = await import ( "web-tree-sitter" )
const { default : treeWasm } = await import ( "web-tree-sitter/tree-sitter.wasm" as string , {
with : { type : "wasm" } ,
} )
await Parser . init ( {
locateFile() {
return treeWasm
} ,
} )
const { default : bashWasm } = await import ( "tree-sitter-bash/tree-sitter-bash.wasm" as string , {
with : { type : "wasm" } ,
} )
const bashLanguage = await Language . load ( bashWasm )
const p = new Parser ( )
p . setLanguage ( bashLanguage )
return p
2025-07-31 21:19:56 +00:00
} )
2025-07-31 00:57:52 +00:00
2025-07-25 17:29:29 +00:00
export const BashTool = Tool . define ( "bash" , {
2025-05-19 23:29:38 +00:00
description : DESCRIPTION ,
parameters : z.object ( {
2025-06-04 17:12:13 +00:00
command : z.string ( ) . describe ( "The command to execute" ) ,
2025-07-29 21:39:31 +00:00
timeout : z.number ( ) . describe ( "Optional timeout in milliseconds" ) . optional ( ) ,
2025-06-04 17:12:13 +00:00
description : z
. string ( )
. describe (
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'" ,
) ,
2025-05-19 23:29:38 +00:00
} ) ,
2025-06-10 21:56:05 +00:00
async execute ( params , ctx ) {
2025-10-28 22:32:39 +00:00
if ( params . timeout !== undefined && params . timeout < 0 ) {
2025-11-08 01:59:02 +00:00
throw new Error ( ` Invalid timeout value: ${ params . timeout } . Timeout must be a positive number. ` )
2025-10-28 22:32:39 +00:00
}
2025-05-31 18:41:00 +00:00
const timeout = Math . min ( params . timeout ? ? DEFAULT_TIMEOUT , MAX_TIMEOUT )
2025-07-31 21:19:56 +00:00
const tree = await parser ( ) . then ( ( p ) = > p . parse ( params . command ) )
2025-10-31 19:07:36 +00:00
if ( ! tree ) {
throw new Error ( "Failed to parse command" )
}
2025-08-12 15:39:39 +00:00
const permissions = await Agent . get ( ctx . agent ) . then ( ( x ) = > x . permission . bash )
2025-07-31 00:57:52 +00:00
2025-09-14 14:01:57 +00:00
const askPatterns = new Set < string > ( )
2025-07-31 00:57:52 +00:00
for ( const node of tree . rootNode . descendantsOfType ( "command" ) ) {
2025-10-31 19:07:36 +00:00
if ( ! node ) continue
2025-07-31 00:57:52 +00:00
const command = [ ]
for ( let i = 0 ; i < node . childCount ; i ++ ) {
const child = node . child ( i )
if ( ! child ) continue
if (
child . type !== "command_name" &&
child . type !== "word" &&
child . type !== "string" &&
child . type !== "raw_string" &&
child . type !== "concatenation"
) {
continue
}
command . push ( child . text )
}
// not an exhaustive list, but covers most common cases
if ( [ "cd" , "rm" , "cp" , "mv" , "mkdir" , "touch" , "chmod" , "chown" ] . includes ( command [ 0 ] ) ) {
for ( const arg of command . slice ( 1 ) ) {
2025-08-01 14:10:09 +00:00
if ( arg . startsWith ( "-" ) || ( command [ 0 ] === "chmod" && arg . startsWith ( "+" ) ) ) continue
2025-08-03 14:30:00 +00:00
const resolved = await $ ` realpath ${ arg } `
. quiet ( )
. nothrow ( )
. text ( )
. then ( ( x ) = > x . trim ( ) )
2025-08-01 01:41:48 +00:00
log . info ( "resolved path" , { arg , resolved } )
2025-11-17 07:06:44 +00:00
if ( resolved ) {
// Git Bash on Windows returns Unix-style paths like /c/Users/...
const normalized =
process . platform === "win32" && resolved . match ( /^\/[a-z]\// )
? resolved . replace ( /^\/([a-z])\// , ( _ , drive ) = > ` ${ drive . toUpperCase ( ) } : \\ ` ) . replace ( /\//g , "\\" )
: resolved
if ( ! Filesystem . contains ( Instance . directory , normalized ) ) {
throw new Error (
` This command references paths outside of ${ Instance . directory } so it is not allowed to be executed. ` ,
)
}
2025-07-31 00:57:52 +00:00
}
}
}
// always allow cd if it passes above check
2025-09-14 14:01:57 +00:00
if ( command [ 0 ] !== "cd" ) {
2025-11-08 01:59:02 +00:00
const action = Wildcard . allStructured ( { head : command [ 0 ] , tail : command.slice ( 1 ) } , permissions )
2025-08-06 00:14:28 +00:00
if ( action === "deny" ) {
throw new Error (
2025-08-12 15:39:39 +00:00
` The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${ JSON . stringify ( permissions ) } ` ,
2025-08-06 00:14:28 +00:00
)
}
2025-09-14 14:01:57 +00:00
if ( action === "ask" ) {
const pattern = ( ( ) = > {
2025-10-31 05:52:46 +00:00
if ( command . length === 0 ) return
const head = command [ 0 ]
// Find first non-flag argument as subcommand
const sub = command . slice ( 1 ) . find ( ( arg ) = > ! arg . startsWith ( "-" ) )
2025-09-14 14:01:57 +00:00
return sub ? ` ${ head } ${ sub } * ` : ` ${ head } * `
} ) ( )
if ( pattern ) {
askPatterns . add ( pattern )
}
}
2025-07-31 00:57:52 +00:00
}
}
2025-09-14 14:01:57 +00:00
if ( askPatterns . size > 0 ) {
const patterns = Array . from ( askPatterns )
2025-07-31 00:57:52 +00:00
await Permission . ask ( {
2025-07-31 20:38:31 +00:00
type : "bash" ,
2025-09-14 14:01:57 +00:00
pattern : patterns ,
2025-07-31 00:57:52 +00:00
sessionID : ctx.sessionID ,
2025-07-31 14:34:43 +00:00
messageID : ctx.messageID ,
2025-07-31 20:38:31 +00:00
callID : ctx.callID ,
2025-07-31 00:57:52 +00:00
title : params.command ,
metadata : {
command : params.command ,
2025-09-14 14:01:57 +00:00
patterns ,
2025-07-31 00:57:52 +00:00
} ,
} )
}
2025-07-31 14:34:43 +00:00
2025-10-16 19:39:36 +00:00
const proc = spawn ( params . command , {
2025-10-12 04:24:48 +00:00
shell : true ,
2025-09-01 21:15:49 +00:00
cwd : Instance.directory ,
2025-11-04 01:25:35 +00:00
env : {
. . . process . env ,
} ,
2025-10-12 04:24:48 +00:00
stdio : [ "ignore" , "pipe" , "pipe" ] ,
2025-10-16 19:39:36 +00:00
detached : process.platform !== "win32" ,
2025-05-31 18:41:00 +00:00
} )
2025-08-03 19:34:37 +00:00
2025-08-11 05:23:00 +00:00
let output = ""
// Initialize metadata with empty output
ctx . metadata ( {
metadata : {
output : "" ,
description : params.description ,
} ,
} )
2025-10-16 19:39:36 +00:00
const append = ( chunk : Buffer ) = > {
2025-08-11 05:23:00 +00:00
output += chunk . toString ( )
ctx . metadata ( {
metadata : {
2025-10-16 19:39:36 +00:00
output ,
2025-08-11 05:23:00 +00:00
description : params.description ,
} ,
} )
2025-10-16 19:39:36 +00:00
}
2025-08-11 05:23:00 +00:00
2025-10-16 19:39:36 +00:00
proc . stdout ? . on ( "data" , append )
proc . stderr ? . on ( "data" , append )
let timedOut = false
let aborted = false
let exited = false
2025-08-03 19:34:37 +00:00
2025-10-16 19:39:36 +00:00
const killTree = async ( ) = > {
const pid = proc . pid
if ( ! pid || exited ) {
return
}
if ( process . platform === "win32" ) {
await new Promise < void > ( ( resolve ) = > {
const killer = spawn ( "taskkill" , [ "/pid" , String ( pid ) , "/f" , "/t" ] , { stdio : "ignore" } )
killer . once ( "exit" , resolve )
killer . once ( "error" , resolve )
} )
return
}
try {
process . kill ( - pid , "SIGTERM" )
2025-10-16 22:48:51 +00:00
await Bun . sleep ( SIGKILL_TIMEOUT_MS )
2025-10-16 19:39:36 +00:00
if ( ! exited ) {
process . kill ( - pid , "SIGKILL" )
}
} catch ( _e ) {
proc . kill ( "SIGTERM" )
2025-10-16 22:48:51 +00:00
await Bun . sleep ( SIGKILL_TIMEOUT_MS )
2025-10-16 19:39:36 +00:00
if ( ! exited ) {
proc . kill ( "SIGKILL" )
}
}
}
if ( ctx . abort . aborted ) {
aborted = true
await killTree ( )
}
const abortHandler = ( ) = > {
aborted = true
void killTree ( )
}
ctx . abort . addEventListener ( "abort" , abortHandler , { once : true } )
const timeoutTimer = setTimeout ( ( ) = > {
timedOut = true
void killTree ( )
} , timeout )
await new Promise < void > ( ( resolve , reject ) = > {
const cleanup = ( ) = > {
clearTimeout ( timeoutTimer )
ctx . abort . removeEventListener ( "abort" , abortHandler )
}
proc . once ( "exit" , ( ) = > {
exited = true
cleanup ( )
2025-08-03 17:51:59 +00:00
resolve ( )
} )
2025-08-03 19:34:37 +00:00
2025-10-16 19:39:36 +00:00
proc . once ( "error" , ( error ) = > {
exited = true
cleanup ( )
reject ( error )
} )
2025-08-11 05:23:00 +00:00
} )
2025-06-03 17:08:47 +00:00
2025-08-12 20:14:40 +00:00
if ( output . length > MAX_OUTPUT_LENGTH ) {
output = output . slice ( 0 , MAX_OUTPUT_LENGTH )
output += "\n\n(Output was truncated due to length limit)"
2025-08-12 18:51:13 +00:00
}
2025-10-16 19:39:36 +00:00
if ( timedOut ) {
2025-10-06 04:55:01 +00:00
output += ` \ n \ n(Command timed out after ${ timeout } ms) `
}
2025-10-16 19:39:36 +00:00
if ( aborted ) {
output += "\n\n(Command was aborted)"
}
2025-05-19 23:29:38 +00:00
return {
2025-07-07 19:53:43 +00:00
title : params.command ,
2025-06-03 17:08:47 +00:00
metadata : {
2025-08-11 05:23:00 +00:00
output ,
2025-10-16 19:39:36 +00:00
exit : proc.exitCode ,
2025-06-04 17:12:13 +00:00
description : params.description ,
2025-06-03 17:08:47 +00:00
} ,
2025-08-11 05:23:00 +00:00
output ,
2025-05-31 18:41:00 +00:00
}
2025-05-19 23:29:38 +00:00
} ,
2025-05-31 18:41:00 +00:00
} )