Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/utils/Shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,42 @@ function isExecutable(shellPath: string): boolean {
* Determines the best available shell to use.
*/
export async function findSuitableShell(): Promise<string> {
// Windows-only one-shot warning: if WSL bash launcher is on PATH and no
// Git for Windows bash is detected, surface a warning so the user knows
// why hooks/BashTool will misbehave. The actual filtering lives in
// findExecutableWithDeps (windowsPaths.ts); this is purely user-facing.
if (
getPlatform() === 'windows' &&
!process.env.CLAUDE_CODE_GIT_BASH_PATH &&
!process.env.CLAUDE_CODE_GIT_BASH_PATH_WARNED
) {
try {
const whereResult = execFileSync('where.exe', ['bash'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
})
Comment on lines +85 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate Shell.ts =="
fd -a 'Shell\.ts$' . || true

echo "== git diff stat/name-status =="
git diff --stat HEAD~1..HEAD 2>/dev/null || git diff --stat 2>/dev/null || true
git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only 2>/dev/null || true

echo "== inspect Shell.ts outline/sections =="
if [ -f src/utils/Shell.ts ]; then
  wc -l src/utils/Shell.ts
  sed -n '1,160p' src/utils/Shell.ts
fi

echo "== search execFileSync/spawn usage =="
rg -n "execFileSync|execFileSync|spawn\(|Bun\.spawn|where\.exe|bash" src package.json bun.lock 2>/dev/null || true

Repository: claude-code-best/claude-code

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect Shell.ts outline/sections =="
if [ -f src/utils/Shell.ts ]; then
  wc -l src/utils/Shell.ts
  sed -n '1,160p' src/utils/Shell.ts
fi

echo "== search process execution and Shell findSuitableShell callers =="
rg -n "findSuitableShell|execFileSync|execFileSync|spawn\(|Bun\.spawn|where\.exe|bash" src package.json bun.lock 2>/dev/null || true

echo "== deterministic source shape check using Python =="
python3 - <<'PY'
from pathlib import Path
p = Path('src/utils/Shell.ts')
if not p.exists():
    raise SystemExit('src/utils/Shell.ts not found')
text = p.read_text()
checks = {
    'has_async_findSuitableShell': 'async function findSuitableShell' in text or 'findSuitableShell(' in text and 'async' in text[:text.find('findSuitableShell(') + 200],
    'has_execFileSync': 'execFiles ync' in text or 'execFileSync' in text,
    'has_where_exe': 'where.exe' in text,
    'has_Bun_spawn_in_Shell': 'Bun.spawn' in text,
}
print(checks)
# Print exact lines with symbols.
for i, line in enumerate(text.splitlines(), 1):
    if any(s in line for s in ['findSuitableShell', 'execFileSync', 'where.exe', 'Bun.spawn', 'timeout']):
        print(f'{i}: {line}')
PY

Repository: claude-code-best/claude-code

Length of output: 50387


Use asynchronous Bun process execution for the PATH lookup.

findSuitableShell() is async, so execFileSync('where.exe', ['bash']) blocks shell discovery until where.exe returns and can hang without a timeout. Replace this lookup with Bun subprocess execution, keep failures ignored, and add a bounded timeout for the lookup.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync, spawn } from 'child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/Shell.ts` around lines 85 - 88, Update the where.exe lookup inside
findSuitableShell() to use asynchronous Bun subprocess execution instead of
execFileSync, while preserving ignored failures and the existing PATH-detection
behavior. Apply a bounded timeout to the subprocess so shell discovery cannot
hang.

Source: Coding guidelines

const lines = whereResult
.split(/\r?\n/)
.map(l => l.trim().toLowerCase())
.filter(Boolean)
const hasWslBash = lines.some(l =>
/(?:system32|windowsapps)\\bash\.exe$/.test(l),
)
const hasGitBash = lines.some(l => /\\git\\.*bash\.exe$/.test(l))
if (hasWslBash && !hasGitBash) {
process.env.CLAUDE_CODE_GIT_BASH_PATH_WARNED = '1'
console.warn(
'[CCB] Detected WSL bash on PATH without Git for Windows. ' +
'Hooks and BashTool will not work correctly. ' +
'Install Git for Windows (https://git-scm.com/download/windows) ' +
'or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe.',
Comment on lines +80 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the Git Bash resolver for the availability check.

where.exe bash only reports Bash executables on PATH. It does not determine whether a usable Git Bash exists. The resolver in src/utils/windowsPaths.ts also validates CLAUDE_CODE_GIT_BASH_PATH, derives Bash from Git, and searches standard locations.

  • src/utils/Shell.ts#L80-L103: Keep the WSL launcher check, but use the resolver result to determine whether a usable Git Bash or valid override exists. The current nonempty override check suppresses warnings for invalid paths.
  • src/utils/doctorDiagnostic.ts#L369-L386: Use the same resolver result. The current diagnostic warns even when a valid CLAUDE_CODE_GIT_BASH_PATH override exists.

Add regression coverage for a valid override and for Git for Windows that is discoverable without appearing in where.exe bash. The PR objective requires Git Bash-or-override detection.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync, spawn } from 'child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

📍 Affects 2 files
  • src/utils/Shell.ts#L80-L103 (this comment)
  • src/utils/doctorDiagnostic.ts#L369-L386
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/Shell.ts` around lines 80 - 103, Replace the PATH-only Git Bash
availability logic in src/utils/Shell.ts lines 80-103 with the shared resolver
from src/utils/windowsPaths.ts, while preserving the WSL launcher check; use the
resolver result so valid overrides and Git for Windows found in standard
locations suppress the warning, but invalid overrides do not. Apply the same
resolver-based decision in src/utils/doctorDiagnostic.ts lines 369-386 so the
diagnostic does not warn when a valid override or discoverable Git Bash exists.
Add regression coverage for both a valid CLAUDE_CODE_GIT_BASH_PATH override and
Git for Windows unavailable from where.exe bash.

)
}
} catch {
// where.exe not available or failed — fall through silently
}
}

// Check for explicit shell override first
const shellOverride = process.env.CLAUDE_CODE_SHELL
if (shellOverride) {
Expand Down
51 changes: 51 additions & 0 deletions src/utils/__tests__/windowsPaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,4 +322,55 @@ describe('findGitBashPathOrNullWithDeps', () => {
const result = findGitBashPathOrNullWithDeps(deps)
expect(result).toBe(expectedBash)
})

test('rejects WSL bash launcher (System32) and returns null when no Git Bash exists', () => {
// WSL feature on Windows ships C:\Windows\System32\bash.exe — a Linux
// distro launcher, NOT Git Bash. It must be rejected; if no Git for
// Windows exists, return null so findGitBashPath can exit(1) cleanly.
const wslBash = 'C:\\Windows\\System32\\bash.exe'
const deps: GitBashDiscoveryDeps = {
// Only the WSL launcher "exists" — Git Bash default locations do not,
// so step 4 fallback (searchDefaultBashLocations) also returns null.
checkExists: p => p === wslBash,
execCommand: cmd => (cmd.includes('where.exe bash') ? wslBash : ''),
cwdFn: () => 'C:\\safe\\cwd',
envOverride: '',
}
expect(findGitBashPathOrNullWithDeps(deps)).toBe(null)
})

test('rejects WSL App Execution Alias (WindowsApps) and returns null when no Git Bash exists', () => {
// %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe is the WSL App Execution
// Alias — a 0-byte reparse point that forwards to WSL. Same rejection.
const wslBash =
'C:\\Users\\foo\\AppData\\Local\\Microsoft\\WindowsApps\\bash.exe'
const deps: GitBashDiscoveryDeps = {
checkExists: p => p === wslBash,
execCommand: cmd => (cmd.includes('where.exe bash') ? wslBash : ''),
cwdFn: () => 'C:\\safe\\cwd',
envOverride: '',
}
expect(findGitBashPathOrNullWithDeps(deps)).toBe(null)
})

test('skips WSL bash and falls through to next where.exe hit (Git Bash later in PATH)', () => {
// Common case: user has WSL enabled AND Git for Windows installed.
// where.exe returns System32\bash.exe first (System32 is near front of
// PATH), then Git Bash later. Filter must skip the WSL entry and accept
// the legit one.
const wslBash = 'C:\\Windows\\System32\\bash.exe'
const legitBash = 'C:\\Program Files\\Git\\bin\\bash.exe'
const deps: GitBashDiscoveryDeps = {
checkExists: p => p === legitBash || p === wslBash,
execCommand: cmd => {
if (cmd.includes('where.exe bash')) {
return `${wslBash}\r\n${legitBash}`
}
return ''
},
cwdFn: () => 'C:\\safe\\cwd',
envOverride: '',
}
expect(findGitBashPathOrNullWithDeps(deps)).toBe(legitBash)
})
})
27 changes: 27 additions & 0 deletions src/utils/doctorDiagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,33 @@ async function detectConfigurationIssues(
// Parse errors are surfaced by the settings loader itself.
}

// Windows-only: detect WSL bash launcher on PATH without Git for Windows.
// Independent of install method — affects every Windows user with WSL.
if (getPlatform() === 'windows') {
try {
const whereResult = await execFileNoThrow('where.exe', ['bash'])
if (whereResult.code === 0 && whereResult.stdout) {
const lines = whereResult.stdout
.split(/\r?\n/)
.map(l => l.trim().toLowerCase())
.filter(Boolean)
const hasWslBash = lines.some(l =>
/(?:system32|windowsapps)\\bash\.exe$/.test(l),
)
const hasGitBash = lines.some(l => /\\git\\.*bash\.exe$/.test(l))
if (hasWslBash && !hasGitBash) {
warnings.push({
issue:
'Windows PATH has WSL bash (C:\\Windows\\System32\\bash.exe) but no Git for Windows bash',
fix: 'Install Git for Windows (https://git-scm.com/download/windows). Without it, CCB cannot run hooks or BashTool correctly on Windows. If you cannot install it, set CLAUDE_CODE_GIT_BASH_PATH to a working bash.exe.',
})
}
}
} catch {
// where.exe not available or failed — skip this check
}
}

const config = getGlobalConfig()

// Skip most warnings for development mode
Expand Down
17 changes: 17 additions & 0 deletions src/utils/windowsPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ function findExecutableWithDeps(
continue
}

// WSL-bash rejection: System32\bash.exe (WSL launcher) and
// WindowsApps\bash.exe (WSL App Execution Alias) must never be
// accepted as Git Bash. They look like bash but spawn wsl.exe,
// causing popup windows and Linux-semantics commands on Windows
// tasks. Only applies to bash.exe, not other executables looked
// up here (git, etc.). Continue to the next where.exe hit so a
// legit Git Bash later in PATH can still win.
if (
executable === 'bash' &&
/(?:system32|windowsapps)\\bash\.exe$/.test(normalizedPath)
) {
logForDebugging(
`Skipping WSL bash launcher (not Git Bash): ${candidatePath}`,
)
continue
}

// Return the first valid path that's not in the current directory
return candidatePath
}
Expand Down