Claude Code のフックが黙って効かなくなる3経路を実測した

目次
  1. フックに何を任せているのか
  2. 経路1: grep で判定すると PATH が欠けた時点で開く
  3. 判定を bash 組み込みに寄せると閉じたまま
  4. 経路2: stdin の読み方でも同じ穴が開く
  5. 経路3: 変数展開1つでフックごと落ちる
  6. 3経路に共通していること
  7. 限界

Claude Code の規律をフックに降ろす、という話はもう十分に流通している。私も .env を読ませないフックを書いた。ではその規律は、書いたあと必ず効いているのか。書いたフックを自分で壊しにいったところ、判定のロジックは一度も間違っていないのに拒否が返らなくなる経路が3つ出てきた。どれも判定にたどり着く前にある。

フックに何を任せているのか

PreToolUse フックは、標準入力にツール呼び出しの JSON を受け取り、exit 2 を返すとその呼び出しを止める(Hooks reference)。判定を stdout の JSON で返す方法もあるが、今回書いたのは exit code だけを返す形である。

この形をとる限り、判定に失敗した経路がそのまま exit 0 に落ちれば拒否は返らない。効いているかどうかは「ちゃんと拒否できたか」ではなく「拒否できなかったときに何を返すか」で決まる。

以下の検証で Claude Code 本体は一度も起動していない。 フックは stdin に JSON を受け取って exit code を返すだけのシェルスクリプトなので、同じ入力を手で流せばスクリプト側の判定は再現する。ただし「その exit code を受けた Claude Code が実際にどう振る舞うか」は測っていない。ここで測ったのはフック側だけである。

実行環境:

$ bash -c 'bash --version | head -1; sw_vers -productVersion'
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
26.6.1
# exit=0 (29 ms)

bash は PATH 解決なので、標準の /bin/bash も直接確かめた。

$ /bin/bash -c '/bin/bash --version | head -1'
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
# exit=0 (5 ms)

macOS に標準で入っている /bin/bash は 3.2 系である。最後の経路でこれが効いてくる。 Claude Code 側のバージョンも控えておく。

$ bash -c 'claude --version'
2.1.220 (Claude Code)
# exit=0 (516 ms)

流し込む payload は、.env を読もうとしている呼び出しを模したもの1本にした。

{"tool_name":"Read","tool_input":{"file_path":"/Users/example/app/.env"}}

経路1: grep で判定すると PATH が欠けた時点で開く

最初に書いたのはこれである。JSON を文字列として受け取り、grep でパターンを探す。

#!/bin/bash
#: 判定に外部コマンド(grep)を使う版
set -uo pipefail
payload="$(cat)"
if printf '%s' "$payload" | grep -q '"file_path":"[^"]*\.env"'; then
  echo "ブロック: .env は読ませない" >&2
  exit 2
fi
exit 0

通常の環境では期待どおり止まる。

$ bash -c './hook-grep.sh < payload.json; echo "exit=$?"'
exit=2
ブロック: .env は読ませない
# exit=0 (23 ms)

PATH が通っていない状態で同じものを走らせるとこうなる。

$ bash -c 'PATH=/nonexistent ./hook-grep.sh < payload.json; echo "exit=$?"'
exit=0
./hook-grep.sh: line 4: cat: command not found
./hook-grep.sh: line 5: grep: command not found
# exit=0 (18 ms)

このログでは catgrep の両方が消えている。どちらが効いたのか分からないので、 cat だけを置いたディレクトリを PATH にして測り直した。

$ bash -c 'PATH="$PWD/minbin" ./hook-grep.sh < payload.json; echo "exit=$?"'
exit=0
./hook-grep.sh: line 5: grep: command not found
# exit=0 (8 ms)

cat は動くので payload は届いている。それでも grep が無いだけで exit 0 に落ちた。判定に使う外部コマンドの不在だけで拒否が消えることが、これで単独に言えた。

ここで効いているのは「パターンが一致しなかった」ではなく **「判定そのものが実行されなかった」**ことである。条件式が外部コマンドの成否に依存していると、コマンドが無い状態は「一致しなかった」と区別が付かない。

command not found は stderr に出ている。ただしフックの stderr がどこまで表示されるかは確かめていないので、ここでは「exit code は 0 だった」という事実だけを取る。

判定を bash 組み込みに寄せると閉じたまま

同じ判定を、外部コマンドを使わずに書き直す。case はパターンマッチを組み込みで持っている。

#!/bin/bash
#: 判定を bash 組み込みの case で行う版(外部コマンドに依存しない)
set -uo pipefail
payload=""
while IFS= read -r line || [ -n "$line" ]; do payload="$payload$line"; done
case "$payload" in
  *'"file_path":"'*'.env"'*)
    echo "ブロック: .env は読ませない" >&2
    exit 2 ;;
esac
exit 0

PATH を潰した同じ条件で走らせる。

$ bash -c 'PATH=/nonexistent ./hook-case.sh < payload.json; echo "exit=$?"'
exit=2
ブロック: .env は読ませない
# exit=0 (14 ms)

exit 2 のままだった。caseread も組み込みなので、PATH に何も無くても動く。外部コマンドを呼ばない限り、command not found で分岐が崩れる余地がそもそも無い。

書き直すとき「case は glob だから [^"]* のような否定は書けない」と思い込んでいた。半分は誤りで、半分は当たっていた。順に測る。

#!/bin/bash
#: case のパターンで否定ブラケットが使えるかを確かめる版
#: 否定ブラケットは引用符の外に置く(引用符の中はリテラル扱いになる)
set -uo pipefail
payload=""
while IFS= read -r line || [ -n "$line" ]; do payload="$payload$line"; done
case "$payload" in
  *'"file_path":"'[^\"]*'.env"'*)
    echo "ブロック: 否定ブラケットで一致した" >&2
    exit 2 ;;
esac
echo "一致しなかった" >&2
exit 0
$ bash -c './hook-negclass.sh < payload.json; echo "exit=$?"'
exit=2
ブロック: 否定ブラケットで一致した
# exit=0 (13 ms)

一致はした。ただしこれは否定ブラケットが効いた証拠にならない。この payload は "file_path":".env" の間に " を含まないので、否定ブラケットを単なる * に置き換えても同じく一致する。区別するには、間に " を挟んだ payload が要る。

そこで間に ","x":" が入る payload を流し、* で書いた版と並べた。否定が効いていれば前者だけ不一致になるはずである。

$ bash -c 'echo "[否定ブラケット]"; ./hook-negclass.sh < payload-quoted.json; echo "exit=$?"; echo "[* で書いた版]"; ./hook-star.sh < payload-quoted.json; echo "exit=$?"'
[否定ブラケット]
exit=2
[* で書いた版]
exit=2
ブロック: 否定ブラケットで一致した
ブロック: * で一致した
# exit=0 (536 ms)

両方とも一致した。 否定ブラケットを書いても * と結果が変わらない。ここで手が止まったので、ブラケット単体まで分解して測った。

#!/bin/bash
#: glob の否定ブラケットが「連続」に効くのかを確かめる
set -uo pipefail

echo "[1] ブラケット単体は否定として効くか"
case '"' in [^\"]) echo '    [^"] は " に一致した' ;; *) echo '    [^"] は " に一致しない' ;; esac
case 'a'  in [^\"]) echo '    [^"] は a に一致した' ;; *) echo '    [^"] は a に一致しない' ;; esac

echo "[2] [^\"]* は「引用符を含まない連続」を表せるか"
between_has_quote='"file_path":"/a","x":"b.env"'
case "$between_has_quote" in
  '"file_path":"'[^\"]*'.env"') echo '    間に " があるのに一致した' ;;
  *) echo '    間に " があるので不一致' ;;
esac

echo "[3] 1文字ぶんだけ否定してみる(* を付けない)"
case '"file_path":"a.env"' in
  '"file_path":"'[^\"]'.env"') echo '    1文字版: 一致' ;; *) echo '    1文字版: 不一致' ;;
esac
case '"file_path":""".env"' in
  '"file_path":"'[^\"]'.env"') echo '    1文字が " の版: 一致' ;; *) echo '    1文字が " の版: 不一致' ;;
esac
$ ./glob-negclass.sh
[1] ブラケット単体は否定として効くか
    [^"] は " に一致しない
    [^"] は a に一致した
[2] [^"]* は「引用符を含まない連続」を表せるか
    間に " があるのに一致した
[3] 1文字ぶんだけ否定してみる(* を付けない)
    1文字版: 一致
    1文字が " の版: 不一致
# exit=0 (379 ms)

これで分かった。ブラケット単体は否定として正しく効く([1] と [3])。効かないのは * を付けたときで、glob の * は直前の要素にかかる量指定子ではない。否定ブラケットに * を続けた形は、正規表現の「引用符以外の0回以上の繰り返し」ではなく **「引用符以外の1文字」+「任意の文字列」**と読まれる。だから後ろに " が来ても通る。

つまり「文字クラスの否定は書ける。ただし連続には効かない」が正しかった。「引用符を含まない範囲」を glob だけで表す方法は、この形では無い。

引用符の内側に置いた場合も確かめた。

$ bash -c './hook-negclass-inside.sh < payload.json; echo "exit=$?"'
exit=0
一致しなかった
# exit=0 (286 ms)

クオートの内側に書くとリテラルの文字列として扱われ、一致しない。ブラケットを使いたければクオートの外に出す必要はある —— ただし出したところで、上のとおり連続の否定にはならない。

もう1つ、read ループで payload="$payload$line" と連結すると改行が落ちる。今回の payload は1行なので影響しなかったが、整形済みの複数行 JSON でどうなるかは測っていない。1行前提で書くなら、その前提をコメントに残しておかないと後から気づけない。

経路2: stdin の読み方でも同じ穴が開く

判定を case に変えれば安全か、というとそうではなかった。判定が組み込みでも、stdin を cat で読んでいると同じ経路が残る

判定部分は同じにして、読み方だけを変えた2本を用意した。cat 版はこうである。

#!/bin/bash
#: stdin を cat で読む版(判定は case。受信バイト数も出す)
set -uo pipefail
payload="$(cat)"
printf 'received=%s bytes\n' "${#payload}" >&2
case "$payload" in
  *'"file_path":"'*'.env"'*) echo "ブロック" >&2; exit 2 ;;
esac
exit 0
$ bash -c 'PATH=/nonexistent ./hook-cat.sh < payload.json; echo "exit=$?"'
exit=0
./hook-cat.sh: line 4: cat: command not found
received=0 bytes
# exit=0 (15 ms)

payload が空文字列になり、どのパターンにも一致せず、素通りしている。判定ロジックは正しいのに、判定する材料が届いていない。cat は「あるのが当たり前」の代表格なので、ここを疑う発想が出てこなかった。

読み方も組み込みにした版は、同じ条件で 73 バイトを受け取った。

#!/bin/bash
#: stdin を read ループで読む版(外部コマンド不要)
set -uo pipefail
payload=""
while IFS= read -r line || [ -n "$line" ]; do payload="$payload$line"; done
printf 'received=%s bytes\n' "${#payload}" >&2
case "$payload" in
  *'"file_path":"'*'.env"'*) echo "ブロック" >&2; exit 2 ;;
esac
exit 0
$ bash -c 'PATH=/nonexistent ./hook-read.sh < payload.json; echo "exit=$?"'
exit=2
received=73 bytes
ブロック
# exit=0 (13 ms)

塞ぐ箇所は判定だけではない、というのがここでの収穫だった。入力・判定のどちらか一方でも外部コマンドに依存していると、そこが開く。

なお JSON パーサ(jq など)を使う書き方は今回測っていない。jq が無いときに明示的に exit 2 で止める形なら閉じられるはずだが、確かめていない。

経路3: 変数展開1つでフックごと落ちる

最後の1つは PATH と関係がない。フックのログにメッセージを日本語で書いていて踏んだ。

#!/bin/bash
set -u
MODE=cover
echo "モード: $MODE(文字を入れない)"
echo "ここまで到達した"

$MODE の直後が全角の括弧である。これを走らせるとこうなる。

$ bash -c './bare-var.sh >stdout.txt 2>stderr.bin; echo "exit=$?"; echo "--- stdout ---"; cat stdout.txt; echo "--- stderr(バイト表現) ---"; od -An -c stderr.bin | head -3'
exit=1
--- stdout ---
--- stderr(バイト表現) ---
           .   /   b   a   r   e   -   v   a   r   .   s   h   :       l
           i   n   e       4   :       M   O   D   E 357   :       u   n
           b   o   u   n   d       v   a   r   i   a   b   l   e  \n    
# exit=0 (20 ms)

エラーメッセージの変数名が MODE ではなく、そこに1バイト(8進 357 = 0xEF)がくっついている。bash 3.2 は変数名の切れ目をマルチバイト文字の境界で判断せず、全角括弧の1バイト目までを変数名に食っているMODE\xef などという変数は存在しないので set -u が異常終了させる。stdout は空で、ここまで到達した は出力されていない。引用を od -An -c に通してあるのは、エラーメッセージ自体に不正な UTF-8 バイトが混じっていて、そのままでは文字として表示できないためである。

波括弧で閉じれば起きない。

$ bash -c './braced-var.sh; echo "exit=$?"'
モード: cover(文字を入れない)
ここまで到達した
exit=0
# exit=0 (12 ms)

これが3つ目の経路なのは、フックが途中で死ねば後続の判定行に到達しないからである。返るのは exit 1 で、拒否を意味する 2 ではない。ただし Claude Code 2.1.220 が exit 1 を受けたときに実際どう振る舞うかは確かめていない。ここで言えるのは「拒否のための exit 2 は返らなかった」までである。

3経路に共通していること

判定のロジックは3回とも正しかった。開いていたのは、いつも判定の手前である。

経路1(判定に使う grep)と経路2(入力に使う cat)は、同じ機構 —— 外部コマンドの不在 —— の別の位置である。同じ原因だからまとめてよいとは思わなかった。塞ぐ場所が違うからで、判定だけを組み込みに寄せても、入力が cat のままなら開いたままになる。 grep だけを欠いた条件と cat だけを欠いた条件は別々に測ってある。経路3はそもそもスクリプトが死ぬので、判定行に到達しない。

私が採った形はこうである。

  • stdin は read ループで読む(cat を使わない)
  • 判定は case のパターンマッチで行う(grep を使わない)
  • 外部コマンドがどうしても要るなら、不在を検知した時点で exit 2 で止める
  • 変数展開の直後に非ASCII文字が来るときは必ず ${VAR} と波括弧で閉じる

最後の1つは目視では見つけられない。全角括弧の直前で変数が閉じているかどうかは、等幅フォントで並べても差が出ないからである。機械で落とす形にすべきだと考えているが、その検査を実際に組んで回すところまでは今回やっていない。

限界

測ったのは macOS 26.6.1 の bash 3.2.57 だけである。bash 5 系では変数展開の挙動が違う可能性があるが、手元に無いので確かめていない。PATH が実際に欠ける条件の切り分けもしていない。ここで示したのは「PATH が欠けたときにどう壊れるか」であって、「どういうときに PATH が欠けるか」ではない。Claude Code 側が各 exit code をどう扱うかも測っていない。

それでも、拒否のつもりで書いたフックが exit 2 を返さなくなることと、その3経路が手元で1秒未満で再現できることは確かめられた。規律をフックに降ろしたら、その規律が外れる条件も一度は測っておいたほうがいい。

関連書籍