Find the TODOs in src
grep -rn TODO src --include=*.ts
src/app.ts:12: // TODO retry on timeout
SafeZsh for Claude
Stops zsh errors such as no matches found in Claude Code's Bash tool. Claude writes bash, the Bash tool runs zsh, and this plugin makes zsh read those commands like bash.
Without it, the command above stops with no matches found: --include=*.ts and grep never runs.
Install
At the Claude Code terminal prompt:
/plugin install safe-zsh --marketplace M1nu0x0/claude-safe-zshAnswer y to Add marketplace? and choose the user scope.
From a shell:
claude plugin install safe-zsh --marketplace M1nu0x0/claude-safe-zshIn Claude Desktop, click + next to the prompt box → Plugins → Add marketplace, enter M1nu0x0/claude-safe-zsh, then add safe-zsh. It applies from the next session.
Before and after
$ grep -rn TODO src --include=*.ts
(eval):1: no matches found: --include=*.ts
$ echo =====; ls
(eval):1: ==== not found
$ T="a.py b.py"; pytest $T
ERROR: file or directory not found: a.py b.py$ grep -rn TODO src --include=*.ts
src/app.ts:12: // TODO retry on timeout
$ echo =====; ls
=====
README.md src
$ T="a.py b.py"; pytest $T
2 passedWhy
On macOS the login shell is zsh, so the Bash tool runs commands in zsh, but Claude writes them for bash.
anthropics/claude-code#103027 counted 2,570 zsh errors in 61,040 Bash calls over twelve weeks. Since late September it was 51 per 1,000 calls, and 76% of them were silent: the command still exited 0, so the error sat in the middle of the output and Claude read on.
Once that issue is fixed in Claude Code, this plugin is no longer needed. CI checks the issue every day.
Claude Code's safety check has a zsh problem of its own: it cannot parse ${(flags)...} and refuses those commands as a possible dangerous rm (#104925). Once that is fixed, the PreToolUse hook and its hint go too. CI checks this issue every day as well.
How it works
At session start (startup, resume, clear, compact) a SessionStart hook appends one line to CLAUDE_ENV_FILE. Claude Code prepends that file to every Bash tool command, so the line runs in the same shell.
if [ -n "${ZSH_VERSION:-}" ]; then setopt no_nomatch sh_word_split no_equals; fiUnder bash the condition is false and the line exits 0, so a command chained after it still runs.
If the shell is zsh, the hook also prints two lines for the model. A zsh -c it starts inside a command does not inherit these options, so its script should begin with setopt no_nomatch sh_word_split no_equals;, or use bash -c. And zsh ${(flags)...} expansions are refused by the safety check before they run, so it should write POSIX sh such as printf '%s\n' "$PATH" | tr ':' '\n' instead. The model may ignore either.
A PreToolUse hook reads each Bash tool command before Claude Code's safety check does. If it uses a zsh ${(flags)...} expansion outside single quotes, such as ${(s/:/)PATH}, the hook denies it and tells the model why: the check would refuse it as a possible dangerous rm before it runs, so it should be written in POSIX sh, such as printf '%s\n' "$PATH" | tr ':' '\n'. The hook never approves anything, and it does not wrap commands in zsh -c to get them past the check.
What it fixes
no_nomatch- An unmatched glob such as
--include=*.tsorls libs/*/README.mdis passed through instead of aborting the command. sh_word_split- An unquoted
$varsplits into words, sofor f in $listloops once per item. no_equals- A word that starts with
=stays as it is:echo =====prints, and=lsis not turned into/bin/ls. ${(flags)...}- A zsh
${(flags)...}expansion such asprint -l ${(s/:/)PATH}is no longer refused with a misleadingdangerous rm operationsprompt: the hook denies it with the real reason, and the model rewrites it in POSIX sh.
It does not fix colon modifiers ($B:path), $pat[...] read as a subscript, path and status as special variables, or bash-only syntax such as ${!v} and compgen. For those, CLAUDE_CODE_SHELL=/bin/bash runs the Bash tool in bash instead. Other syntax the safety check cannot parse, such as $${, is still refused with the rm message.
Related issues
Badges show each issue's current state.
- #103027
Claude keeps writing bash syntax under zsh. The reason for this plugin.
- #67146
The earlier report of the same problem, closed for inactivity.
- #103576
Unsplit words made pytest run no tests, and the empty result was reported as is.
- #74888
Monitor tool examples assume bash word splitting.
- #101554
A bash builtin run under zsh. Not something this plugin can fix.
- #74746
Run the Bash tool under bash. Closed with
CLAUDE_CODE_SHELL.- #7490
Choose the Bash tool's shell, or inherit the one Claude Code was started from.
- #91498
The Bash tool runs the login shell, so rename it or flag shell errors.
- #52521
Rename the
Bashtool.- #2444
The Bash tool runs in the default shell, not bash.
- #24573
The Bash tool picks zsh even for a bash user.
- #104925
A command without
rmis refused as a possible dangerousrmwhen it fails to parse, as zsh${(flags)...}does. The reason for the PreToolUse hook.- #99249
zsh -cscripts also seem to be judged by bash grammar.- #100419
The PermissionRequest hook input has no reason for the refusal, so the cause cannot be told apart.
- #99899
In a subagent this approval prompt has no auto-deny timeout and waits forever.
Questions
Does it change my terminal or ~/.zshrc?
No. The options apply only to the Bash tool's shell, not to your terminal, your dotfiles or a zsh -c started inside a command. For that last case the hook only tells the model how to start it.
I use bash. Does it do anything?
Almost nothing. Under bash the line's condition is false and it exits 0. The PreToolUse hook still denies a command with a zsh ${(flags)...} expansion, which bash could not run either.
Why not just use CLAUDE_CODE_SHELL=/bin/bash?
That works and fixes more, but your zsh functions and settings are no longer available in the Bash tool. This plugin keeps zsh and turns off only the three behaviours that break most often.
Does it send data anywhere?
No. The SessionStart hook appends one line to a file Claude Code gives it and prints two fixed lines for the model. The PreToolUse hook reads the Bash command from its input and, if it has a zsh ${(flags)...} expansion, prints a fixed deny. Neither makes a network request.
Which systems is it tested on?
CI runs on Ubuntu and macOS, and every day against the latest Claude Code.
How do I remove it?
Run claude plugin uninstall safe-zsh in a shell.
Why does (eval):1: no matches found appear?
(eval) means the error came from the Bash tool's zsh, which runs each command with eval. In zsh a glob that matches no file is an error, so the whole command stops there, while bash passes the word on as it is. This plugin makes zsh pass it on too (no_nomatch). In a terminal the same error reads zsh: no matches found.
What is ==== not found?
zsh replaces a word that starts with = with the path of the command it names (=ls becomes /bin/ls), so echo ===== looks for a command named ==== and fails. Commands that print a row of = as a separator run into this. This plugin turns that off (no_equals).
Why is a command without rm refused with could not be checked for dangerous rm operations?
Claude Code's safety check parses a command before any shell runs it, and its bash grammar has no zsh ${(flags)...} expansion such as ${(s/:/)PATH}. A command it cannot parse is refused as a possible dangerous rm, even with no rm in it (#104925). setopt cannot help, because zsh never starts. This plugin's PreToolUse hook denies such a command first and tells the model to write it in POSIX sh, such as printf '%s\n' "$PATH" | tr ':' '\n'.