source dump of claude code
at main 255 lines 11 kB view raw
1import { feature } from 'bun:bundle' 2import { access } from 'fs/promises' 3import { tmpdir as osTmpdir } from 'os' 4import { join as nativeJoin } from 'path' 5import { join as posixJoin } from 'path/posix' 6import { rearrangePipeCommand } from '../bash/bashPipeCommand.js' 7import { createAndSaveSnapshot } from '../bash/ShellSnapshot.js' 8import { formatShellPrefixCommand } from '../bash/shellPrefix.js' 9import { quote } from '../bash/shellQuote.js' 10import { 11 quoteShellCommand, 12 rewriteWindowsNullRedirect, 13 shouldAddStdinRedirect, 14} from '../bash/shellQuoting.js' 15import { logForDebugging } from '../debug.js' 16import { getPlatform } from '../platform.js' 17import { getSessionEnvironmentScript } from '../sessionEnvironment.js' 18import { getSessionEnvVars } from '../sessionEnvVars.js' 19import { 20 ensureSocketInitialized, 21 getClaudeTmuxEnv, 22 hasTmuxToolBeenUsed, 23} from '../tmuxSocket.js' 24import { windowsPathToPosixPath } from '../windowsPaths.js' 25import 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 */ 39function 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 58export 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}