Claude Code には hook という仕組みがある。設定ファイルに登録しておくと、
Claude が道具を使う直前や直後に自分のシェルスクリプトが走る。
PreToolUse に登録したスクリプトが終了コード 2 を返すと、その操作は実行前に止まる。
危険なコマンドを自分の手で止める安全網として使える。
この記事は、その安全網を自分で書いている人向けの話だ。
私は Claude Code を自動承認モード(確認を挟まない設定)で長く動かしていて、
そのために安全網を910本ほど書いて配っている。
先日、そのうちの1本に「処理を早く終わらせる関門」を足した。筋は通っていたし、
手元の試験も通った。だが CI が2件で捕まえた。関門が、そのフックが本来止めるものを
通していた。この記事はその2件の中身と、同じことを自分のフックで確かめる手順を書く。
そもそも何を直そうとしていたのか
配っている安全フックの1本が、こういうコマンドを止めていた。
echo "rm -rf / は絶対に打つな"
git commit -m "guard against rm -rf /"
grep -r "rm -rf /" .
どれも何も削除しない。危険なコマンドの名前を文字列として書いているだけだ。
検索することは実行することではないし、注意書きをファイルに書くのも実行ではない。
だが多くのフックは、渡ってきたコマンドの文字列を正規表現で見る。
引用符の中だろうが外だろうが、同じ文字列として当たる。だから止まる。
この誤検知は軽くない。止められた人はその日にフックを外す。
守りが1つ減るのは、誤検知1件よりずっと高くつく。私も自分のフックにこれで
1日7回止められたことがあって、その時の記録が残っている。
直し方は分かっていた
引用の中を空白へ潰したテキストを作り、そこに破壊的な動詞が残っているかだけを見る。
OUTSIDE=$(printf '%s' "$COMMAND" | awk -v SQ="'" -v DQ='"' '
{ line = $0; out = ""; q = 0
for (i = 1; i <= length(line); i++) {
c = substr(line, i, 1)
if (q == 0 && c == SQ) { q = 1; out = out " "; continue }
if (q == 1) { if (c == SQ) q = 0; out = out " "; continue }
if (q == 0 && c == DQ) { q = 2; out = out " "; continue }
if (q == 2) { if (c == DQ) q = 0; out = out " "; continue }
out = out c
}
print out }')
echo "rm -rf /" はこれを通すと echo になる。rm が消える。
一方 rm -rf "/" は rm -rf になり、rm は残る。
だから「潰した後に破壊的な動詞がコマンドの先頭に立っているか」を見れば、
言及と実行を分けられる。立っていなければ、後ろの判定を1つも走らせずに終わってよい。
if ! printf '%s' "$OUTSIDE" | grep -qE '(^|[;&|])[[:space:]]*(rm|rmdir|dd|chmod)([[:space:]]|$)'; then
exit 0
fi
1本目のフックでは、これがそのまま効いた。誤検知が消えて、引用符つきの本物
(rm -rf "/" のような形)は止まったままになった。試験を24件書いて、
直す前の版に当てると13件落ちることも確かめた。
2本目で、同じものが逆に働いた
同じ関門を別のフックへ入れた。手元の試験は緑だった。CIが落ちた。
FAIL: cd && git filter-repo --path foo (expected exit 2, got 0)
FAIL: cat foo > /dev/sda (expected exit 2, got 0)
どちらも、そのフックが本来止めるものだ。 関門が先に exit 0 を返したので、
判定の本体に一度も届いていなかった。
原因は関門の作りにある。私は関門を「破壊的な動詞の一覧」で書いた。
ところがそのフックの判定は、動詞では表せない範囲を見ていた。
-
git filter-repo— 一覧にはfilter-branchを入れていたがfilter-repoが無かった -
cat foo > /dev/sda— リダイレクトでデバイスへ書く形。これは動詞ではない
つまり関門は、後ろの判定が見ているものの一部しか名指しできていなかった。
ここが一般化できる一点
早く抜ける道は、後ろにある判定の全部を名指しできる時だけ置ける。
名指しできない関門は、守りを狭めるのではなく緩める。
同じ関門が、1本目では正しく狭め、2本目では緩めた。関門の中身は同じだ。
違ったのは、後ろにある判定の広さだった。
これは速度のための最適化に限らない。「この場合は明らかに安全だから先に返す」という
枝を安全な処理に足す時、いつでも同じ形で起きる。
早期リターンは、その先を読まずに書ける。だから危ない。
私は2本目からこの関門を外した。修理して残す道もあったが、
一覧に足し続ける形は「次に何が漏れているか」を誰も答えられない。
外した理由をファイルの中に書いて、同じ試みを次に読む人が繰り返さないようにした。
自分のフックで確かめる手順
判定に渡すだけなので、危険なコマンドは一度も実行されない。
1. 後ろの判定が何を見ているか、行で数える
grep -cE 'exit 2' your-hook.sh
grep -nE 'grep -qE|case .* in' your-hook.sh | wc -l
判定の数と、関門の一覧に入れた語の数を並べてみる。
判定のほうが多いなら、その差が素通りの候補だ。
2. 関門を入れる前と後で、同じ入力を流して比べる
for f in before after; do
git checkout "$f" -- your-hook.sh
while read -r cmd; do
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd" \
| bash your-hook.sh >/dev/null 2>&1
echo "$? $cmd"
done < cases.txt > "result-$f.txt"
done
diff result-before.txt result-after.txt
差分に「2 → 0」が出たら、それが緩めた場所。 「0 → 2」は締めた場所だ。
片方だけを見ないこと。私は締めたほうだけを数えて満足していた。
3. 試験は「直す前の版に当てて落ちること」まで確かめる
通ることより、直す前に落ちることのほうが、その試験が何かを測っている証拠になる。
落ちない試験は、欠陥をそのまま仕様として記録してしまう。
4. 試験の母数を、自分の目で数える
私はここでも間違えた。「全部走らせた」と書いた53本は、試験の総数の5分の1だった。
実行スクリプトの for f in tests/*.test.sh が、tests/test-*.sh という別の命名を
拾っていなかった。実際は267本ある。
ls tests/ | wc -l # 母数
# 自分のスクリプトが走らせた本数と、この数を突き合わせる
「全部走らせるスクリプトを書いた」は、全部走らせたことの証明ではない。
おまけ:この作業中に、同じ型の誤検知を3回踏んだ
言及と実行を見分ける関門を書いている最中に、自分の別のフックに3回止められた。
1回は正しい発火だった。残る2回は、2つのファイルを1行でステージしただけだ。
git add examples/some-hook.sh tests/some-hook.test.sh
前のファイル名の末尾の .sh と、次のファイル名の test.sh が並んで、
sh でテストを実行していると読まれた。並べる順を変えたら通った。
3回目は、この記事の下書きのファイル名だった。
...-loosens-the-guard-2026-08-12.md と付けたら、guard という語のせいで
「安全装置そのものの書き換え」と判定されて、書き込みが止まった。
名前から the-guard を外したら通った。
直している欠陥の型は、たいてい他の場所にも在る。1本直して終わりにしないほうがいい。
この手の「動いているのに間違っている」設定の事故は、症状が出ないので気づけない。
自分の環境で踏んだものを100章ぶん集めて、検出・復旧・予防の形にまとめた本がある
(Anthropic公式ガイドにない事故防止・¥800)。
巻頭に「症状から該当章へ飛べる早見表」を無料で置いてあるので、
いま困っている症状があるならそこだけ見てもらえれば足りる。
フックそのものは全部無料で配っている(cc-safe-setup)。
この記事の関門も、直した後の形が入っている。