/ src / server / tools / bash / execute.ts
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  }