claude-safe-zsh

src の TODO を探して

grep -rn TODO src --include=*.ts

src/app.ts:12: // TODO retry on timeout

SafeZsh for Claude

Claude Code の Bash ツールで起きる no matches found などの zsh のエラーを防ぎます。Claude は bash で書き、Bash ツールは zsh で実行します。このプラグインは zsh にそのコマンドを bash と同じように読ませます。

このプラグインがないと、上のコマンドは no matches found: --include=*.ts で止まり、grep は実行されません。

インストール

Claude Code のターミナルのプロンプトで:

/plugin install safe-zsh --marketplace M1nu0x0/claude-safe-zsh

Add marketplace? に y と答え、scope は user を選びます。

シェルからは:

claude plugin install safe-zsh --marketplace M1nu0x0/claude-safe-zsh

Claude Desktop では、プロンプト横の + → Plugins → Add marketplace に M1nu0x0/claude-safe-zsh を入力し、safe-zsh を追加します。次のセッションから適用されます。

前と後

$ 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
そのままの zsh
$ 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 passed
このプラグインを入れた zsh

作った理由

macOS ではログインシェルが zsh なので Bash ツールも zsh でコマンドを実行しますが、Claude は bash の書き方でコマンドを書きます。

anthropics/claude-code#103027 は 12 週間で Bash の呼び出し 61,040 回のうち 2,570 回の zsh エラーを数えました。9 月下旬からは 1,000 回あたり 51 回で、その 76% はコマンドが 0 で終わったため、エラーが出力の途中に埋もれたまま Claude が読み進めていました。

この issue が Claude Code で修正されれば、このプラグインは不要になります。CI が毎日 issue の状態を確認しています。

Claude Code の安全チェックにも zsh の問題があります。${(flags)...} を解析できず、そうしたコマンドを rm の危険として拒否します(#104925)。これが修正されれば PreToolUse フックとその案内も外します。CI が毎日この issue の状態も確認しています。

動作

セッション開始時(startup、resume、clear、compact)に SessionStart フックが CLAUDE_ENV_FILE に 1 行を追記します。Claude Code はこのファイルを Bash ツールのコマンドごとに先頭に付けるので、その行が同じシェルで実行されます。

if [ -n "${ZSH_VERSION:-}" ]; then setopt no_nomatch sh_word_split no_equals; fi

bash では条件が偽になり 0 で終わるので、後に続くコマンドもそのまま実行されます。

シェルが zsh のとき、フックはモデルにもう 2 行伝えます。コマンドの中で起動した zsh -c はこれらのオプションを引き継がないので、そのスクリプトの先頭に setopt no_nomatch sh_word_split no_equals; を付けるか bash -c を使うようにという案内と、zsh の ${(flags)...} 展開は安全チェックが実行前に拒否するので printf '%s\n' "$PATH" | tr ':' '\n' のように POSIX sh で書くようにという案内です。モデルが従わないことがあります。

PreToolUse フックは、Claude Code の安全チェックより先に Bash ツールのコマンドを読みます。シングルクォートの外に ${(s/:/)PATH} のような zsh の ${(flags)...} 展開があれば拒否し、モデルに理由を伝えます。安全チェックが実行前に rm の危険として拒否するので、printf '%s\n' "$PATH" | tr ':' '\n' のように POSIX sh で書くようにという内容です。フックは何も承認せず、チェックを避けるためにコマンドを zsh -c で包むこともしません。

防げるもの

no_nomatch
--include=*.ts や ls libs/*/README.md のように一致しない glob が、コマンドを止めずにそのまま渡されます。
sh_word_split
クォートしていない $var が単語に分割され、for f in $list が項目ごとに 1 回ずつ回ります。
no_equals
= で始まる単語をそのままにします。echo ===== が出力され、=ls が /bin/ls に変わりません。
${(flags)...}
print -l ${(s/:/)PATH} のような zsh の ${(flags)...} 展開が、的外れな dangerous rm operations のメッセージで拒否されなくなります。フックが本当の理由とともに拒否し、モデルが POSIX sh に書き直します。

コロン修飾子($B:path)、添字として読まれる $pat[...]、特殊変数の path と status、${!v} や compgen のような bash 専用の書き方は防げません。その場合は CLAUDE_CODE_SHELL=/bin/bash で Bash ツールを bash で実行できます。$${ のように安全チェックが解析できない他の書き方は、引き続き rm のメッセージで拒否されます。

関連 issue

バッジは各 issue の現在の状態です。

#103027
Claude が zsh で bash の書き方を使い続ける。このプラグインを作った理由。
#67146
同じ問題の以前の報告。活動がなく閉じられた。
#103576
単語分割されず pytest がテストを 1 つも実行せず、空の結果がそのまま報告された。
#74888
Monitor ツールの例が bash の単語分割を前提にしている。
#101554
zsh で bash の組み込みコマンドを実行した。このプラグインでは防げない。
#74746
Bash ツールを bash で実行したいという要望。CLAUDE_CODE_SHELL で閉じられた。
#7490
Bash ツールのシェルを選ぶか、Claude Code を起動したシェルを引き継ぎたいという要望。
#91498
Bash ツールはログインシェルを実行するので、名前を変えるかシェルのエラーを知らせてほしいという提案。
#52521
Bash ツールの名前変更の提案。
#2444
Bash ツールが bash ではなく既定のシェルで実行されるという報告。
#24573
bash ユーザーなのに Bash ツールが zsh を選ぶ。
#104925
rm のないコマンドでも、zsh の ${(flags)...} のように解析に失敗すると rm の危険として拒否される。PreToolUse フックを作った理由。
#99249
zsh -c のスクリプトも bash の文法で判断されているように見える。
#100419
PermissionRequest フックの入力に拒否の理由がなく、原因を区別できない。
#99899
サブエージェントではこの承認画面に自動拒否の時間がなく、無期限に止まる。

よくある質問

ターミナルや ~/.zshrc は変わりますか?

いいえ。オプションは Bash ツールのシェルにだけ適用されます。ターミナル、dotfile、コマンドの中で起動した zsh -c には適用されません。最後の場合、フックはモデルに起動のしかたを伝えるだけです。

bash を使っています。何かしますか?

ほとんど何もしません。bash では setopt の行の条件が偽になり 0 で終わります。PreToolUse フックは zsh の ${(flags)...} 展開を含むコマンドを拒否しますが、そうしたコマンドは bash でも実行できません。

CLAUDE_CODE_SHELL=/bin/bash を使えばよいのでは?

それでも動き、より多くの問題が解決しますが、Bash ツールで自分の zsh の関数や設定が使えなくなります。このプラグインは zsh のまま、最もよく壊れる 3 つだけをオフにします。

どこかにデータを送りますか?

送りません。SessionStart フックは Claude Code から渡されたファイルに 1 行を追記し、モデルに向けて固定の 2 行を出力します。PreToolUse フックは入力の Bash コマンドを読み、zsh の ${(flags)...} 展開があれば固定の拒否メッセージを出力します。どちらもネットワークリクエストはしません。

どの OS で確認していますか?

Ubuntu と macOS で CI が動き、毎日最新の Claude Code でも確認しています。

削除するには?

シェルで claude plugin uninstall safe-zsh を実行します。

(eval):1: no matches found はなぜ出るのですか?

(eval) は、Bash ツールの zsh がコマンドを eval で実行していて出たエラーという意味です。zsh では一致するファイルがない glob はエラーになり、コマンド全体がそこで止まりますが、bash はその単語をそのまま渡します。このプラグインは zsh でも bash と同じようにそのまま渡します(no_nomatch)。ターミナルでは同じエラーが zsh: no matches found と表示されます。

==== not found とは?

zsh は = で始まる単語を、その名前のコマンドのパスに置き換えます(=ls は /bin/ls)。そのため echo ===== は ==== というコマンドを探して失敗します。区切り線として = を並べて出力するコマンドがこれに当たります。このプラグインはこの動作をオフにします(no_equals)。

rm がないのに、なぜ could not be checked for dangerous rm operations で拒否されるのですか?

Claude Code の安全チェックはシェルが実行する前にコマンドを解析しますが、その bash の文法には ${(s/:/)PATH} のような zsh の ${(flags)...} 展開がありません。解析できないコマンドは、rm がなくても rm の危険として拒否されます(#104925)。zsh が起動すらしないので、setopt では防げません。このプラグインの PreToolUse フックが先に拒否し、printf '%s\n' "$PATH" | tr ':' '\n' のように POSIX sh で書くようモデルに伝えます。