[BUG] `Bash` tool executes the user's login shell (e.g. /bin/zsh) and should thus be called "shell" not "bash"; LLM trips over bash-only idioms

Status Open
Reported on v2.1.258
Maintainer reply None cached
Activity 5 comments · opened Sep 2, 2026

Summary

The Bash tool's description begins "Executes a bash command and returns its output." On macOS with SHELL=/bin/zsh, the command is actually evaluated by the zsh, not bash. The tool name and description therefore assert an interpreter that isn't the one running the command.

This isn't cosmetic: zsh and bash differ in default options that change control flow. The model writes idiomatic bash (because the tool says bash), and the divergence shows up as a failed command with an error that doesn't name the cause.

I hit this organically mid-task, not while hunting for it:

<img width="439" height="153" alt="Image" src="https://github.com/user-attachments/assets/9ecf95b4-02a4-4ff9-a339-e538a3398427" />

It's wrong to call something bash if it's actually zsh. The end user sees bash in the terminal when it's zsh. So Claude saying "I hit a zsh error" is weird when the tool name is "bash". You should call it the "Shell" tool if it's the system shell, not "bash" which is a particular type of shell.

Proof that the interpreter is zsh

$ echo "\$0 = $0"; echo "BASH_VERSION = ${BASH_VERSION:-<unset>}"; echo "ZSH_VERSION = ${ZSH_VERSION:-<unset>}"; ps -o comm= -p $$
$0 = /bin/zsh
BASH_VERSION = <unset>
ZSH_VERSION = 5.9
/bin/zsh

The (eval):1: prefix on shell errors also indicates the command is handed to the shell through eval.

Repro

One Bash tool call:

cd /some/empty/dir
rm -f nope*.txt && echo "FOLLOW-UP RAN"
echo "exit status of the chain: $?"

Actual (via the Bash tool → zsh):

(eval):1: no matches found: nope*.txt
exit status of the chain: 1

Expected (what "Executes a bash command" implies — same line, real bash):

$ /bin/bash -c 'cd /some/empty/dir; rm -f nope*.txt && echo "FOLLOW-UP RAN"; echo "exit status: $?"'
FOLLOW-UP RAN
exit status: 0

zsh's nomatch option is on by default: an unmatched glob is a shell error, so rm never runs and the shell returns nonzero. bash passes the unmatched pattern through literally; rm -f ignores a missing file and exits 0.

Why the current signals aren't sufficient

  • The tool description says "bash" in the first sentence and never mentions that the shell is the user's login shell. It does say "the shell is initialized from the user's profile," which is about initialization, not about which interpreter.
  • The session environment block does say Shell: zsh. So the information exists, but it directly contradicts the tool's own name and description, and the contradiction is only resolvable by running echo $0.
  • The failure mode gives no hint. (eval):1: no matches found: doesn't say "this is zsh, bash would have accepted this."

Other defaults that diverge the same way and would produce silently different behavior: unquoted parameter expansion isn't word-split in zsh, arrays are 1-indexed, ** recursive globbing is on, echo flag handling differs, and set -o pipefail semantics/availability differ across versions.

Environment

  • Environment Info
  • Platform: darwin
  • Terminal: xterm-256color
  • Version: 2.1.258
  • Claude Code 2.1.258
  • macOS 26.6.2 (arm64)
  • SHELL=/bin/zsh, zsh 5.9
  • Feedback ID: b4915ce3-a6ab-4f3e-82e6-9cf58fabaafb

Related

  • #7490 — feature request to configure the Bash tool's shell / inherit the initial shell. That's about choice of shell. This report is narrower: whatever shell is chosen, the tool shouldn't describe itself as bash while running something else, and a shell-level failure shouldn't be indistinguishable from a no-op.

Suggested fix

The name and the interpreter need to agree. Any of these three is coherent — the current combination (called bash, runs zsh) is the only one that isn't:

  1. Actually use bash. Run the command through bash -c, keeping the login shell only for deriving profile env. The name becomes true and bash-idiomatic output from the model becomes correct. (This is roughly the #7490 direction and the biggest behavioral change.)
  2. Name it after the real shell. Call the tool Zsh — or interpolate the detected shell into the name/description — so it says what it runs.
  3. Name it generically. Call it Shell, with a description that states which interpreter is in use ("Executes a command in the user's login shell, currently /bin/zsh"). This is the cheapest option and keeps one stable tool name across users whose shells differ, while still removing the wrong bash prior.

Options 2 and 3 are documentation-only and would each have prevented this instance.

Independently of which is chosen: annotate the failure. When a command exits nonzero with a recognizably shell-specific error (no matches found:, not valid in this context:), append a one-line note to the tool result naming the shell and the divergence. That converts a confusing silent no-op into an actionable error.

View original on GitHub ↗

3 Comments

lukaszkalnik · 1 month ago

Other differences in zsh:

  • $PIPESTATUS does not exist in zsh — the array is $pipestatus, and it is 1-indexed too, so

the first command's status is $pipestatus[1]. ${PIPESTATUS[0]} expands to the empty
string, which turns exit=${PIPESTATUS[0]} into exit= and any comparison against it into
a silent pass.

  • [[ =~ ]] fills $match, not $BASH_REMATCH. ${BASH_REMATCH[1]} is silently empty;

use ${match[1]}.

Additionally there are some other silent failures (at least on macOS):

  • date is BSD date (not GNU): every option must precede the operand.

date -u -j -v-1d -f %Y-%m-%d "$d" +%Y-%m-%d is correct; a -v placed after "$d" is
ignored without an error and yields a full date string instead of the formatted one.

  • $TMPDIR differs inside and outside the sandbox (/tmp/claude-503 vs

/var/folders/…/T/). Never pass a $TMPDIR path between a sandboxed command and one run
with dangerouslyDisableSandbox — use an absolute path both can reach.

janwillemkeizer · 1 month ago

Adding a workaround that exists today, and a related mismatch it exposes.

Workaround: CLAUDE_CODE_SHELL

CLAUDE_CODE_SHELL is documented and makes the Bash tool run a bash or zsh binary of your choice instead of $SHELL. That is option 1 from the suggested fix, available now as user config. In ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_SHELL": "/opt/homebrew/bin/bash" } }

After a restart, the repro from this issue behaves the way the tool name promises (Claude Code 2.1.270, macOS, login shell still /bin/zsh). One Bash tool call:

cd /some/empty/dir
rm -f nope*.txt && echo "FOLLOW-UP RAN"
echo "exit status of the chain: $?"
echo "BASH_VERSION = ${BASH_VERSION:-<unset>}"; echo "SHELL = $SHELL"; ps -o comm= -p $$

Output:

FOLLOW-UP RAN
exit status of the chain: 0
BASH_VERSION = 5.3.15(1)-release
SHELL = /opt/homebrew/bin/bash
/opt/homebrew/bin/bash

Caveats for anyone trying it:

  • Don't point it at macOS's /bin/bash, which is bash 3.2. Use a current bash, e.g. from Homebrew.
  • The shell snapshot then sources ~/.bashrc (and ~/.bash_profile, since it starts a login shell) instead of ~/.zshrc. PATH is inherited from the environment claude starts in, but functions and aliases defined only in zsh startup files, such as nvm's nvm function, need a bash equivalent.

The mismatch it exposes

With the override set, the environment block Claude sees still says Shell: zsh, while every command runs in bash and even $SHELL inside the tool is /opt/homebrew/bin/bash. The issue notes that the environment block is currently the only place the real shell is named; with CLAUDE_CODE_SHELL set, that line is wrong in the other direction.

In 2.1.270 the environment block derives the name from $SHELL alone, while the executor checks CLAUDE_CODE_SHELL first:

// environment block "Shell:" line
let e=a.SHELL||"unknown",n=e.includes("zsh")?"zsh":e.includes("bash")?"bash":e
// Bash tool shell resolution
let e=a.CLAUDE_CODE_SHELL;if(e)if((e.includes("bash")||e.includes("zsh"))&&await obt(e))return t(`Using shell override: ${e}`),e;

Whichever naming option is chosen for the tool, the Shell: line should come from the same resolution the executor uses, so it names the shell that actually runs the commands.

Vultwo · 27 days ago

Confirming this, with a worse failure mode than the examples above: the tool reports success while silently doing nothing.

macOS (Darwin 25.6.0), login shell /bin/zsh. A subagent wrote a load generator:

pids=""; for i in 1 2 3 4 5 6 7 8; do ( while :; do :; done ) & pids="$pids $!"; done
kill $pids 2>/dev/null

kill $pids is correct bash. Under zsh, $pids expands to a single word, kill returns 1, and 2>/dev/null discards the only evidence. The trailing command then succeeds, so the tool result was is_error: false. The backgrounded loops reparented to PPID 1 and kept spinning — 40 accumulated across five runs.

Two things the examples above don't cover:

  • Silent success. Every case here surfaces a visible error (no matches found, empty $PIPESTATUS). Here the model saw a green result and believed cleanup ran, so there was no signal to correct against.
  • Trigger is a foreground call — not a tool timeout (#90672), not run_in_background (#89625).

Same mechanism as #74888 (for rp in $list collapsing to one token under zsh), which was closed NOT_PLANNED but scoped to the Monitor tool's own example scripts rather than model-authored commands.

zsh -c 'pids=""; for i in 1 2 3; do sleep 30 & pids="$pids $!"; done;
        set -- $pids; echo "args to kill: $#"; kill $pids 2>/dev/null; echo "rc=$?"'
# args to kill: 1
# rc=1          <- nothing killed, no visible error

Showing cached comments. Read the full discussion on GitHub ↗