個人開発の手元で、「経理」という名前のフォルダだけは Claude Code に触らせたくない、と考えて PreToolUse フックを書いた夜がありました。パスに「経理」が含まれていたら終了コード 2 で止める、という十行ほどの Python です。Mac の私の手元では、期待どおりに止まりました。
ところが、Zenn で Windows のフックが日本語のときだけ黙って壊れるという報告を見つけ、胃のあたりが重くなりました。原因は cp932 とされています。同じ形のフックを書いた身として、他人事ではありませんでした。
私の iMac に Windows の実機はありません。そこで、Python の標準入力を cp932 で読ませて症状を手元で作り、どこで止まり、どこで素通りするのかを数えました。Windows 実機での確認ではない点は、先にお伝えしておきます。
症状は「止まらない」だけで、エラーは見えません
最初にお伝えしたいのは、壊れ方が 2 通りあり、どちらも画面に目立つ形では現れない、ということです。
フックには、ツール呼び出しの内容が JSON で標準入力に渡されます。パスは tool_input.file_path に入っています。Python は標準入力の文字コードを OS の既定で決めるため、日本語版 Windows のように既定が cp932 の環境では、UTF-8 のバイト列を cp932 として読もうとします。
試したフックは次のとおりです。
# guard_bad.py — 「経理」を含むパスの編集を止める(問題のある版)
import json, sys
data = json.load(sys.stdin) # ここで stdin の既定の文字コードが使われる
path = data.get("tool_input", {}).get("file_path", "")
if "経理" in path:
print("経理フォルダは編集できません", file=sys.stderr)
sys.exit(2) # 2 だけが「止める」
sys.exit(0)この版に、PYTHONIOENCODING=cp932 を付けて、UTF-8 の JSON を流しました。Mac 上で Windows の既定を模す、いちばん手軽な方法です。
echo '{"tool_name":"Edit","tool_input":{"file_path":"/Users/demo/経理/売上.csv"}}' \
| PYTHONIOENCODING=cp932 python3 guard_bad.py
echo "exit=$?"結果は UnicodeDecodeError の Traceback と exit=1 でした。UTF-8 のときは「経理フォルダは編集できません」と出て exit=2 になります。
公式の hooks ドキュメントでは、終了コード 2 だけが呼び出しを止め、それ以外の非ゼロは「止めない失敗」として扱われます。つまり 1 で落ちたフックは、守るべきフォルダの編集をそのまま通しながら、エラーを控えめに残すだけです。
もう一つの壊れ方は、例外すら出ません
厄介だったのは、パスの文字によっては例外が出ないことでした。UTF-8 のバイト列が、たまたま cp932 として読める並びになると、Python は何も言わずに別の文字列を返します。
日本語のフォルダ名になりやすい 12 語を、UTF-8 のバイト列にして cp932 で読み直しました。
| 読み直した結果 | 語数 | 例 |
|---|---|---|
| 例外なしで別の文字列になった | 9 語 | 議事録 → 隴ー莠矩鹸/日報 → 譌・蝣ア |
| UnicodeDecodeError になった | 3 語 | 契約書・資料・売上 |
9 語のほうでは、"議事録" in path が偽になり、フックは exit 0 で終わります。エラーも警告もなく、禁止したはずの編集が通ります。「落ちてくれた」ほうが、まだ気づく手がかりが残る分だけ親切でした。
文字化けと日本語ファイルの扱いは、これまでも Claude Code で日本語ファイルが文字化けする時の対処で書いてきました。ただ、ファイルの中身ではなく、フックに渡る入力が化ける場面は、私の中では盲点でした。
なぜ、動いているように見えてしまうのか
私がこの不具合を想像できなかった理由は、フックの動作確認を英数字のパスだけで済ませていたからでした。src/main.py のようなパスなら、cp932 でも UTF-8 でも同じバイト列になります。どちらで読んでも結果が変わらないので、確認が通ったという事実だけが残ります。
もう一つは、止まらなかったときの手がかりの少なさです。ブロックが働いた日は画面に理由が出ますが、働かなかった日には何も出ません。「何も起きなかった」と「守れていた」は、見た目が同じなのです。この非対称が、守るためのフックをいちばん疑いにくいものにしているのだと思います。
直し方は、読む前に UTF-8 と宣言することです
直しは 3 行です。stdin と stderr を UTF-8 に作り直してから JSON を読みます。
# guard_good.py — 修正版
import json, sys
sys.stdin.reconfigure(encoding="utf-8") # 入力を UTF-8 として読む
sys.stderr.reconfigure(encoding="utf-8") # 拒否理由も化けないように
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
if "経理" in path:
print("経理フォルダは編集できません", file=sys.stderr)
sys.exit(2)
sys.exit(0)同じ cp932 の条件で流すと、exit=2 と日本語の拒否理由が返りました。reconfigure は Python 3.7 以降で使えます。
もう一つの手は、フックのコマンドを python -X utf8 guard.py にして、UTF-8 モードで起動することです。ここには一点だけ注意があります。PYTHONIOENCODING が設定されていると、そちらが優先されます。私が手元で -X utf8 を付けても直らなかったのは、検証用に PYTHONIOENCODING=cp932 を残していたためでした。スクリプトの中で reconfigure するほうが、環境変数の影響を受けないぶん確実だと感じています。
直ったかどうかは、日本語の入力を流して確かめます
文字コードの不具合は、英数字のパスでは再現しません。確認には、日本語を含む JSON を実際に流します。
# 止まるべき入力で exit=2 になるか
echo '{"tool_name":"Edit","tool_input":{"file_path":"/Users/demo/経理/売上.csv"}}' \
| PYTHONIOENCODING=cp932 python3 guard_good.py; echo "exit=$?"
# 通すべき入力で exit=0 になるか
echo '{"tool_name":"Edit","tool_input":{"file_path":"/Users/demo/docs/invoice.csv"}}' \
| PYTHONIOENCODING=cp932 python3 guard_good.py; echo "exit=$?"Windows の PowerShell では、$env:PYTHONIOENCODING = "cp932" を先に設定すれば同じ検証になります。実機をお持ちの方は、環境変数を付けずに流した結果もあわせてご覧ください。
線引き
守る側のフックが転んだとき、どちらへ倒れるかは別の記事でも書きました。onFailure を block にしても jq が無い環境では素通りした話です。今回の cp932 は、その入口にあたる「入力を読めない」ケースでした。
守りたいと思うフォルダの名前は、たいてい日本語でつけています。だからこそ、ここで転ぶと気づくのが遅れるのだと思います。
守るフックは、英数字のパスで動いたあとに、日本語のパスで止まるところまで見て、はじめて完成と考えます。 この確認だけは、新しいフックを足すたびに続けるつもりです。
結び
今日できる一番小さな確認は、いまお使いのフックのうち、パスや本文を読んで判断しているものを一つ選び、日本語を含む JSON を流して exit の値を見ることです。2 が返らなければ、そのフックは日本語のフォルダを守れていません。
私も、自分の手元のフックの先頭に reconfigure の 2 行を足すところから始めようと思っております。