/ utils / shell / bashProvider.ts
bashProvider.ts
  1  import { feature } from 'bun:bundle'
  2  import { access } from 'fs/promises'
  3  import { tmpdir as osTmpdir } from 'os'
  4  import { join as nativeJoin } from 'path'
  5  import { join as posixJoin } from 'path/posix'
  6  import { rearrangePipeCommand } from '../bash/bashPipeCommand.js'
  7  import { createAndSaveSnapshot } from '../bash/ShellSnapshot.js'
  8  import { formatShellPrefixCommand } from '../bash/shellPrefix.js'
  9  import { quote } from '../bash/shellQuote.js'
 10  import {
 11    quoteShellCommand,
 12    rewriteWindowsNullRedirect,
 13    shouldAddStdinRedirect,
 14  } from '../bash/shellQuoting.js'
 15  import { logForDebugging } from '../debug.js'
 16  import { getPlatform } from '../platform.js'
 17  import { getSessionEnvironmentScript } from '../sessionEnvironment.js'
 18  import { getSessionEnvVars } from '../sessionEnvVars.js'
 19  import {
 20    ensureSocketInitialized,
 21    getClaudeTmuxEnv,
 22    hasTmuxToolBeenUsed,
 23  } from '../tmuxSocket.js'
 24  import { windowsPathToPosixPath } from '../windowsPaths.js'
 25  import type { ShellProvider } from './shellProvider.js'
 26  
 27  /**
 28   * Returns a shell command to disable extended glob patterns for security.
 29   * Extended globs (bash extglob, zsh EXTENDED_GLOB) can be exploited via
 30   * malicious filenames that expand after our security validation.
 31   *
 32   * When CLAUDE_CODE_SHELL_PREFIX is set, the actual executing shell may differ
 33   * from shellPath (e.g., shellPath is zsh but the wrapper runs bash). In this
 34   * case, we include commands for BOTH shells. We redirect both stdout and stderr
 35   * to /dev/null because zsh's command_not_found_handler writes to STDOUT.
 36   *
 37   * When no shell prefix is set, we use the appropriate command for the detected shell.
 38   */
 39  function getDisableExtglobCommand(shellPath: string): string | null {
 40    // When CLAUDE_CODE_SHELL_PREFIX is set, the wrapper may use a different shell
 41    // than shellPath, so we include both bash and zsh commands
 42    if (process.env.CLAUDE_CODE_SHELL_PREFIX) {
 43      // Redirect both stdout and stderr because zsh's command_not_found_handler
 44      // writes to stdout instead of stderr
 45      return '{ shopt -u extglob || setopt NO_EXTENDED_GLOB; } >/dev/null 2>&1 || true'
 46    }
 47  
 48    // No shell prefix - use shell-specific command
 49    if (shellPath.includes('bash')) {
 50      return 'shopt -u extglob 2>/dev/null || true'
 51    } else if (shellPath.includes('zsh')) {
 52      return 'setopt NO_EXTENDED_GLOB 2>/dev/null || true'
 53    }
 54    // Unknown shell - do nothing, we don't know the right command
 55    return null
 56  }
 57  
 58  export async function createBashShellProvider(
 59    shellPath: string,
 60    options?: { skipSnapshot?: boolean },
 61  ): Promise<ShellProvider> {
 62    let currentSandboxTmpDir: string | undefined
 63    const snapshotPromise: Promise<string | undefined> = options?.skipSnapshot
 64      ? Promise.resolve(undefined)
 65      : createAndSaveSnapshot(shellPath).catch(error => {
 66          logForDebugging(`Failed to create shell snapshot: ${error}`)
 67          return undefined
 68        })
 69    // Track the last resolved snapshot path for use in getSpawnArgs
 70    let lastSnapshotFilePath: string | undefined
 71  
 72    return {
 73      type: 'bash',
 74      shellPath,
 75      detached: true,
 76  
 77      async buildExecCommand(
 78        command: string,
 79        opts: {
 80          id: number | string
 81          sandboxTmpDir?: string
 82          useSandbox: boolean
 83        },
 84      ): Promise<{ commandString: string; cwdFilePath: string }> {
 85        let snapshotFilePath = await snapshotPromise
 86        // This access() check is NOT pure TOCTOU — it's the fallback decision
 87        // point for getSpawnArgs. When the snapshot disappears mid-session
 88        // (tmpdir cleanup), we must clear lastSnapshotFilePath so getSpawnArgs
 89        // adds -l and the command gets login-shell init. Without this check,
 90        // `source ... || true` silently fails and commands run with NO shell
 91        // init (neither snapshot env nor login profile). The `|| true` on source
 92        // still guards the race between this check and the spawned shell.
 93        if (snapshotFilePath) {
 94          try {
 95            await access(snapshotFilePath)
 96          } catch {
 97            logForDebugging(
 98              `Snapshot file missing, falling back to login shell: ${snapshotFilePath}`,
 99            )
100            snapshotFilePath = undefined
101          }
102        }
103        lastSnapshotFilePath = snapshotFilePath
104  
105        // Stash sandboxTmpDir for use in getEnvironmentOverrides
106        currentSandboxTmpDir = opts.sandboxTmpDir
107  
108        const tmpdir = osTmpdir()
109        const isWindows = getPlatform() === 'windows'
110        const shellTmpdir = isWindows ? windowsPathToPosixPath(tmpdir) : tmpdir
111  
112        // shellCwdFilePath: POSIX path used inside the bash command (pwd -P >| ...)
113        // cwdFilePath: native OS path used by Node.js for readFileSync/unlinkSync
114        // On non-Windows these are identical; on Windows, Git Bash needs POSIX paths
115        // but Node.js needs native Windows paths for file operations.
116        const shellCwdFilePath = opts.useSandbox
117          ? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)
118          : posixJoin(shellTmpdir, `claude-${opts.id}-cwd`)
119        const cwdFilePath = opts.useSandbox
120          ? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)
121          : nativeJoin(tmpdir, `claude-${opts.id}-cwd`)
122  
123        // Defensive rewrite: the model sometimes emits Windows CMD-style `2>nul`
124        // redirects. In POSIX bash (including Git Bash on Windows), this creates a
125        // literal file named `nul` — a reserved device name that breaks git.
126        // See anthropics/claude-code#4928.
127        const normalizedCommand = rewriteWindowsNullRedirect(command)
128        const addStdinRedirect = shouldAddStdinRedirect(normalizedCommand)
129        let quotedCommand = quoteShellCommand(normalizedCommand, addStdinRedirect)
130  
131        // Debug logging for heredoc/multiline commands to trace trailer handling
132        // Only log when commit attribution is enabled to avoid noise
133        if (
134          feature('COMMIT_ATTRIBUTION') &&
135          (command.includes('<<') || command.includes('\n'))
136        ) {
137          logForDebugging(
138            `Shell: Command before quoting (first 500 chars):\n${command.slice(0, 500)}`,
139          )
140          logForDebugging(
141            `Shell: Quoted command (first 500 chars):\n${quotedCommand.slice(0, 500)}`,
142          )
143        }
144  
145        // Special handling for pipes: move stdin redirect after first command
146        // This ensures the redirect applies to the first command, not to eval itself.
147        // Without this, `eval 'rg foo | wc -l' \< /dev/null` becomes
148        // `rg foo | wc -l < /dev/null` — wc reads /dev/null and outputs 0, and
149        // rg (with no path arg) waits on the open spawn stdin pipe forever.
150        // Applies to sandbox mode too: sandbox wraps the assembled commandString,
151        // not the raw command (since PR #9189).
152        if (normalizedCommand.includes('|') && addStdinRedirect) {
153          quotedCommand = rearrangePipeCommand(normalizedCommand)
154        }
155  
156        const commandParts: string[] = []
157  
158        // Source the snapshot file. The `|| true` guards the race between the
159        // access() check above and the spawned shell's `source` — if the file
160        // vanishes in that window, the `&&` chain still continues.
161        if (snapshotFilePath) {
162          const finalPath =
163            getPlatform() === 'windows'
164              ? windowsPathToPosixPath(snapshotFilePath)
165              : snapshotFilePath
166          commandParts.push(`source ${quote([finalPath])} 2>/dev/null || true`)
167        }
168  
169        // Source session environment variables captured from session start hooks
170        const sessionEnvScript = await getSessionEnvironmentScript()
171        if (sessionEnvScript) {
172          commandParts.push(sessionEnvScript)
173        }
174  
175        // Disable extended glob patterns for security (after sourcing user config to override)
176        const disableExtglobCmd = getDisableExtglobCommand(shellPath)
177        if (disableExtglobCmd) {
178          commandParts.push(disableExtglobCmd)
179        }
180  
181        // When sourcing a file with aliases, they won't be expanded in the same command line
182        // because the shell parses the entire line before execution. Using eval after
183        // sourcing causes a second parsing pass where aliases are now available for expansion.
184        commandParts.push(`eval ${quotedCommand}`)
185        // Use `pwd -P` to get the physical path of the current working directory for consistency with `process.cwd()`
186        commandParts.push(`pwd -P >| ${quote([shellCwdFilePath])}`)
187        let commandString = commandParts.join(' && ')
188  
189        // Apply CLAUDE_CODE_SHELL_PREFIX if set
190        if (process.env.CLAUDE_CODE_SHELL_PREFIX) {
191          commandString = formatShellPrefixCommand(
192            process.env.CLAUDE_CODE_SHELL_PREFIX,
193            commandString,
194          )
195        }
196  
197        return { commandString, cwdFilePath }
198      },
199  
200      getSpawnArgs(commandString: string): string[] {
201        const skipLoginShell = lastSnapshotFilePath !== undefined
202        if (skipLoginShell) {
203          logForDebugging('Spawning shell without login (-l flag skipped)')
204        }
205        return ['-c', ...(skipLoginShell ? [] : ['-l']), commandString]
206      },
207  
208      async getEnvironmentOverrides(
209        command: string,
210      ): Promise<Record<string, string>> {
211        // TMUX SOCKET ISOLATION (DEFERRED):
212        // We initialize Claude's tmux socket ONLY AFTER the Tmux tool has been used
213        // at least once, OR if the current command appears to use tmux.
214        // This defers the startup cost until tmux is actually needed.
215        //
216        // Once the Tmux tool is used (or a tmux command runs), all subsequent Bash
217        // commands will use Claude's isolated socket via the TMUX env var override.
218        //
219        // See tmuxSocket.ts for the full isolation architecture documentation.
220        const commandUsesTmux = command.includes('tmux')
221        if (
222          process.env.USER_TYPE === 'ant' &&
223          (hasTmuxToolBeenUsed() || commandUsesTmux)
224        ) {
225          await ensureSocketInitialized()
226        }
227        const claudeTmuxEnv = getClaudeTmuxEnv()
228        const env: Record<string, string> = {}
229        // CRITICAL: Override TMUX to isolate ALL tmux commands to Claude's socket.
230        // This is NOT the user's TMUX value - it points to Claude's isolated socket.
231        // When null (before socket initializes), user's TMUX is preserved.
232        if (claudeTmuxEnv) {
233          env.TMUX = claudeTmuxEnv
234        }
235        if (currentSandboxTmpDir) {
236          let posixTmpDir = currentSandboxTmpDir
237          if (getPlatform() === 'windows') {
238            posixTmpDir = windowsPathToPosixPath(posixTmpDir)
239          }
240          env.TMPDIR = posixTmpDir
241          env.CLAUDE_CODE_TMPDIR = posixTmpDir
242          // Zsh uses TMPPREFIX (default /tmp/zsh) for heredoc temp files,
243          // not TMPDIR. Set it to a path inside the sandbox tmp dir so
244          // heredocs work in sandboxed zsh commands.
245          // Safe to set unconditionally — non-zsh shells ignore TMPPREFIX.
246          env.TMPPREFIX = posixJoin(posixTmpDir, 'zsh')
247        }
248        // Apply session env vars set via /env (child processes only, not the REPL)
249        for (const [key, value] of getSessionEnvVars()) {
250          env[key] = value
251        }
252        return env
253      },
254    }
255  }