execute.ts
1 import { spawn } from 'node:child_process' 2 import { readFile, stat } from 'node:fs/promises' 3 import { 4 basename, 5 isAbsolute as isAbsolutePath, 6 relative as relativePath, 7 resolve as resolvePath, 8 } from 'node:path' 9 import process from 'node:process' 10 import { 11 appendTruncatedText, 12 BASH_TOOL_MAX_COMMAND_CHARS, 13 BASH_TOOL_MAX_OUTPUT_CHARS, 14 BASH_TOOL_PREFLIGHT_MAX_SCRIPT_BYTES, 15 clampBashToolTimeout, 16 sanitizeCommandOutput, 17 } from './limits' 18 19 interface BashToolCallInput { 20 argumentsJson: string 21 allowDangerousBashTool: boolean 22 workspaceCwd?: string 23 } 24 25 export async function executeBashToolCall( 26 input: BashToolCallInput, 27 ): Promise<Record<string, unknown>> { 28 if (!input.allowDangerousBashTool) { 29 return { 30 ok: false, 31 tool: 'bash', 32 error: 33 'The bash tool is disabled. Enable the nuclear bash toggle in the UI before using it.', 34 } 35 } 36 37 const parsedArgs = safeParseJsonObject(input.argumentsJson) 38 const command = 39 typeof parsedArgs.command === 'string' ? parsedArgs.command.trim() : '' 40 if (!command) { 41 return { 42 ok: false, 43 tool: 'bash', 44 error: 'A non-empty `command` string is required.', 45 } 46 } 47 if (command.length > BASH_TOOL_MAX_COMMAND_CHARS) { 48 return { 49 ok: false, 50 tool: 'bash', 51 error: `Command exceeds ${BASH_TOOL_MAX_COMMAND_CHARS} characters.`, 52 } 53 } 54 55 const requestedCwd = 56 typeof parsedArgs.cwd === 'string' && parsedArgs.cwd.trim() 57 ? parsedArgs.cwd.trim() 58 : null 59 const defaultCwd = input.workspaceCwd ?? process.cwd() 60 const cwd = requestedCwd 61 ? isAbsolutePath(requestedCwd) 62 ? requestedCwd 63 : resolvePath(defaultCwd, requestedCwd) 64 : defaultCwd 65 66 const timeoutMs = clampBashToolTimeout( 67 typeof parsedArgs.timeoutMs === 'number' && 68 Number.isFinite(parsedArgs.timeoutMs) 69 ? parsedArgs.timeoutMs 70 : null, 71 ) 72 73 const preflightError = await runBashToolScriptPreflight({ command, cwd }) 74 if (preflightError) { 75 return { 76 ok: false, 77 tool: 'bash', 78 command, 79 cwd, 80 timeoutMs, 81 preflightBlocked: true, 82 error: preflightError, 83 } 84 } 85 86 try { 87 const result = await runBashCommandTool({ 88 command, 89 cwd, 90 timeoutMs, 91 }) 92 93 return { 94 ok: true, 95 tool: 'bash', 96 command, 97 cwd, 98 timeoutMs, 99 exitCode: result.exitCode, 100 signal: result.signal, 101 timedOut: result.timedOut, 102 failureReason: result.failureReason, 103 stdout: result.stdout, 104 stderr: result.stderr, 105 truncated: { 106 stdout: result.stdoutTruncated, 107 stderr: result.stderrTruncated, 108 }, 109 } 110 } catch (error) { 111 return { 112 ok: false, 113 tool: 'bash', 114 command, 115 cwd, 116 timeoutMs, 117 error: error instanceof Error ? error.message : 'bash execution failed', 118 } 119 } 120 } 121 122 async function runBashCommandTool(input: { 123 command: string 124 cwd: string 125 timeoutMs: number 126 }): Promise<{ 127 exitCode: number | null 128 signal: string | null 129 timedOut: boolean 130 failureReason: string | null 131 stdout: string 132 stderr: string 133 stdoutTruncated: boolean 134 stderrTruncated: boolean 135 }> { 136 const shellBinary = process.platform === 'win32' ? 'bash' : '/bin/bash' 137 138 return await new Promise((resolve, reject) => { 139 const child = spawn(shellBinary, ['-lc', input.command], { 140 cwd: input.cwd, 141 stdio: ['ignore', 'pipe', 'pipe'], 142 env: process.env, 143 }) 144 145 let stdout = '' 146 let stderr = '' 147 let stdoutTruncated = false 148 let stderrTruncated = false 149 let timedOut = false 150 let settled = false 151 152 const timeout = setTimeout(() => { 153 timedOut = true 154 child.kill('SIGKILL') 155 }, input.timeoutMs) 156 157 child.stdout.setEncoding('utf8') 158 child.stderr.setEncoding('utf8') 159 160 child.stdout.on('data', (chunk: string) => { 161 const normalizedChunk = sanitizeCommandOutput(chunk) 162 if (!normalizedChunk) return 163 const next = appendTruncatedText( 164 stdout, 165 normalizedChunk, 166 BASH_TOOL_MAX_OUTPUT_CHARS, 167 ) 168 stdout = next.value 169 stdoutTruncated = stdoutTruncated || next.truncated 170 }) 171 child.stderr.on('data', (chunk: string) => { 172 const normalizedChunk = sanitizeCommandOutput(chunk) 173 if (!normalizedChunk) return 174 const next = appendTruncatedText( 175 stderr, 176 normalizedChunk, 177 BASH_TOOL_MAX_OUTPUT_CHARS, 178 ) 179 stderr = next.value 180 stderrTruncated = stderrTruncated || next.truncated 181 }) 182 183 child.on('error', (error) => { 184 clearTimeout(timeout) 185 if (settled) return 186 settled = true 187 reject(error) 188 }) 189 190 child.on('close', (code, signal) => { 191 clearTimeout(timeout) 192 if (settled) return 193 settled = true 194 const exitCode = typeof code === 'number' ? code : null 195 const normalizedSignal = typeof signal === 'string' ? signal : null 196 resolve({ 197 exitCode, 198 signal: normalizedSignal, 199 timedOut, 200 failureReason: inferBashFailureReason({ 201 exitCode, 202 signal: normalizedSignal, 203 timedOut, 204 timeoutMs: input.timeoutMs, 205 }), 206 stdout, 207 stderr, 208 stdoutTruncated, 209 stderrTruncated, 210 }) 211 }) 212 }) 213 } 214 215 function inferBashFailureReason(input: { 216 exitCode: number | null 217 signal: string | null 218 timedOut: boolean 219 timeoutMs: number 220 }): string | null { 221 if (input.timedOut) { 222 return `Command timed out after ${input.timeoutMs}ms.` 223 } 224 if (input.exitCode === 127) { 225 return 'Command not found (exit 127).' 226 } 227 if (input.exitCode === 126) { 228 return 'Command not executable or permission denied (exit 126).' 229 } 230 if (input.signal) { 231 return `Command terminated by signal ${input.signal}.` 232 } 233 if (typeof input.exitCode === 'number' && input.exitCode !== 0) { 234 return `Command exited with code ${input.exitCode}.` 235 } 236 return null 237 } 238 239 async function runBashToolScriptPreflight(input: { 240 command: string 241 cwd: string 242 }): Promise<string | null> { 243 const target = extractScriptTargetFromBashCommand(input.command) 244 if (!target) return null 245 246 const absolutePath = isAbsolutePath(target.relOrAbsPath) 247 ? resolvePath(target.relOrAbsPath) 248 : resolvePath(input.cwd, target.relOrAbsPath) 249 const resolvedCwd = resolvePath(input.cwd) 250 if (!isPathWithinDirectory(resolvedCwd, absolutePath)) { 251 return null 252 } 253 254 try { 255 const targetStats = await stat(absolutePath) 256 if (!targetStats.isFile()) return null 257 if (targetStats.size > BASH_TOOL_PREFLIGHT_MAX_SCRIPT_BYTES) return null 258 259 const content = await readFile(absolutePath, 'utf8') 260 const envVarMatch = /\$[A-Z_][A-Z0-9_]{1,}/g.exec(content) 261 if (envVarMatch) { 262 const matchIndex = envVarMatch.index 263 const line = content.slice(0, matchIndex).split('\n').length 264 const token = envVarMatch[0] 265 const guidance = 266 target.kind === 'python' 267 ? `In Python, use os.environ.get(${JSON.stringify(token.slice(1))}) instead of raw ${token}.` 268 : `In Node.js, use process.env[${JSON.stringify(token.slice(1))}] instead of raw ${token}.` 269 return [ 270 `exec preflight: detected likely shell variable injection (${token}) in ${target.kind} script: ${basename(absolutePath)}:${line}.`, 271 guidance, 272 '(If this is intentionally inside a string literal, escape it or restructure the code.)', 273 ].join('\n') 274 } 275 276 if (target.kind === 'node') { 277 const firstNonEmptyLine = content 278 .split(/\r?\n/) 279 .map((line) => line.trim()) 280 .find((line) => line.length > 0) 281 if (firstNonEmptyLine && /^NODE\b/.test(firstNonEmptyLine)) { 282 return ( 283 `exec preflight: JS file starts with shell syntax (${firstNonEmptyLine}). ` + 284 'This looks like a shell command, not JavaScript.' 285 ) 286 } 287 } 288 } catch { 289 // Best-effort preflight only; ignore missing/unreadable files. 290 return null 291 } 292 293 return null 294 } 295 296 function isPathWithinDirectory(baseDirectory: string, targetPath: string): boolean { 297 const relativeTarget = relativePath(baseDirectory, targetPath) 298 return ( 299 relativeTarget === '' || 300 (!relativeTarget.startsWith('..') && !isAbsolutePath(relativeTarget)) 301 ) 302 } 303 304 function extractScriptTargetFromBashCommand( 305 command: string, 306 ): { kind: 'python' | 'node'; relOrAbsPath: string } | null { 307 const trimmed = command.trim() 308 if (!trimmed) return null 309 310 const pythonMatch = trimmed.match( 311 /^\s*(python3?|python)\s+(?:-[^\s]+\s+)*([^\s]+\.py)\b/i, 312 ) 313 if (pythonMatch?.[2]) { 314 return { 315 kind: 'python', 316 relOrAbsPath: pythonMatch[2], 317 } 318 } 319 320 const nodeMatch = trimmed.match( 321 /^\s*(node)\s+(?:--[^\s]+\s+)*([^\s]+\.js)\b/i, 322 ) 323 if (nodeMatch?.[2]) { 324 return { 325 kind: 'node', 326 relOrAbsPath: nodeMatch[2], 327 } 328 } 329 330 return null 331 } 332 333 function safeParseJsonObject(value: string): Record<string, unknown> { 334 try { 335 const parsed = JSON.parse(value) as unknown 336 if (typeof parsed === 'object' && parsed !== null) { 337 return parsed as Record<string, unknown> 338 } 339 return {} 340 } catch { 341 return {} 342 } 343 }