As an indie developer, I once wrote a small PreToolUse hook because I wanted one folder, named 経理 (accounting), to stay off-limits to Claude Code. About ten lines of Python: if the path contains that word, exit with code 2. On my Mac it blocked exactly what it should.
Then I found a Zenn report saying that on Windows, hooks quietly break only when Japanese is involved, with cp932 as the suspected cause. I had written the same kind of hook, so it didn't feel like someone else's problem.
I don't have a Windows machine. So I reproduced the symptom by making Python read stdin as cp932 and counted what happened. That is a simulation, not a Windows run, and I'd rather say so up front.
The symptom is only "it doesn't stop", and nothing tells you
The thing I most want to flag is that there are two failure modes, and neither shows up loudly.
Claude Code passes the tool call to a hook as JSON on stdin, with the path in tool_input.file_path. Python picks the stdin encoding from the OS default, so on Japanese Windows it tries to read UTF-8 bytes as cp932.
Here is the hook I tested.
# guard_bad.py — block edits under paths containing 経理 (the broken version)
import json, sys
data = json.load(sys.stdin) # uses whatever stdin encoding Python defaulted to
path = data.get("tool_input", {}).get("file_path", "")
if "経理" in path:
print("The 経理 folder is off-limits", file=sys.stderr)
sys.exit(2) # only 2 means "block"
sys.exit(0)I fed it UTF-8 JSON with PYTHONIOENCODING=cp932, the quickest way I know to imitate the Windows default from a Mac.
echo '{"tool_name":"Edit","tool_input":{"file_path":"/Users/demo/経理/売上.csv"}}' \
| PYTHONIOENCODING=cp932 python3 guard_bad.py
echo "exit=$?"The result was a UnicodeDecodeError traceback and exit=1. With UTF-8 the hook prints its message and returns exit=2.
In the official hooks documentation, only exit code 2 blocks the call; any other non-zero code is treated as a failure that does not stop it. So a hook that dies with 1 lets the edit to the protected folder go through and leaves, at most, a modest error behind.
The second failure mode doesn't even raise
What made me wince was that some paths raise nothing at all. When UTF-8 bytes happen to form a valid cp932 sequence, Python returns a different string without a word.
I took 12 words that commonly show up as Japanese folder names, encoded them as UTF-8 and decoded them as cp932.
| Result of decoding as cp932 | Words | Examples |
|---|---|---|
| Became a different string, no exception | 9 | 議事録 → 隴ー莠矩鹸 / 日報 → 譌・蝣ア |
| Raised UnicodeDecodeError | 3 | 契約書, 資料, 売上 |
For those 9 words, "議事録" in path is false and the hook ends with exit 0. No error, no warning, and the edit you meant to forbid goes through. The crash, oddly, was the kinder outcome, because it at least leaves a trace.
I've written before about Japanese text going wrong inside files, in Fixing Japanese Mojibake in Claude Code. Garbled hook input, rather than garbled file contents, was a blind spot for me.
Why it looks like it's working
The reason I never imagined this bug is that I had only ever tested hooks with ASCII paths. A path like src/main.py produces identical bytes in cp932 and UTF-8, so both readings agree and the only thing left behind is the fact that the check passed.
The other reason is how little a miss leaves behind. When the block works, a reason appears on screen; when it doesn't, nothing does. "Nothing happened" and "it was protected" look exactly alike, which makes a guard hook the hardest kind to doubt.
The fix is to declare UTF-8 before reading
Three lines. Rebuild stdin and stderr as UTF-8 before parsing the JSON.
# guard_good.py — fixed version
import json, sys
sys.stdin.reconfigure(encoding="utf-8") # read input as UTF-8
sys.stderr.reconfigure(encoding="utf-8") # keep the refusal message readable
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
if "経理" in path:
print("The 経理 folder is off-limits", file=sys.stderr)
sys.exit(2)
sys.exit(0)Under the same cp932 condition it returned exit=2 with the message intact. reconfigure works on Python 3.7 and later.
The other route is launching the hook as python -X utf8 guard.py, which turns on UTF-8 mode. One caveat: if PYTHONIOENCODING is set, it wins. -X utf8 didn't help on my machine only because I had left PYTHONIOENCODING=cp932 in place for testing. Calling reconfigure inside the script avoided that, which is why I'd rather rely on it.
Check it with Japanese input, not English
Encoding bugs never appear with ASCII paths. To confirm a fix, feed in JSON that really contains Japanese.
# Should block: expect exit=2
echo '{"tool_name":"Edit","tool_input":{"file_path":"/Users/demo/経理/売上.csv"}}' \
| PYTHONIOENCODING=cp932 python3 guard_good.py; echo "exit=$?"
# Should allow: expect exit=0
echo '{"tool_name":"Edit","tool_input":{"file_path":"/Users/demo/docs/invoice.csv"}}' \
| PYTHONIOENCODING=cp932 python3 guard_good.py; echo "exit=$?"In PowerShell, setting $env:PYTHONIOENCODING = "cp932" first gives the same check. If you have a real Windows machine, please also run it without the variable and compare.
Where I draw the line
Which way a guard hook falls when it fails is something I covered separately in When onFailure Is Set to block but jq Is Missing. The cp932 case is the doorway to that one: the hook can't read its input at all.
The folders I most want to protect tend to have Japanese names, which is exactly why a failure here would take me a long time to notice.
A guard hook counts as finished only after it has passed an ASCII path and then stopped a Japanese one. I intend to keep that check every time I add a new hook.
One thing to try today
Pick one hook that reads a path or message to make its decision, pipe in JSON containing Japanese, and look at the value of exit. If you don't get 2, that hook isn't protecting your Japanese-named folders.
I'm starting with the two reconfigure lines at the top of my own hooks.