安全用の hook を書いて、テストを流しました。
==============================================
判定テスト: OK 50 件 / NG 0 件
設置チェック: 問題なし
結果: OK 50 件 / NG 0 件
==============================================
50件全部OK。これで安全になった、と思いました。
なっていませんでした。同じ実行の、いちばん上をちゃんと読んでいなかったからです。
==============================================
設置チェック
==============================================
[--] まだ .claude/hooks/ に置いていないようなので、設置チェックは省略します。
省略した、と書いてある。 なのに最後の要約は「問題なし」。
自分で書いたスクリプトです。確かめていないものを「問題なし」と表示していました。
この記事は、そこで気づいたことの話です。
判定テストが証明していないこと
自分の書いたテストは、こういう形で動いています。
printf '{"tool_input":{"command":"git push"}}' | bash deny-dangerous.sh
コマンドを JSON にして、スクリプトに直接渡す。返ってきた JSON に "deny" が入っていれば合格。
危険なコマンドは一度も実行しないので、安全に何度でも流せます。ここは良いところです。
でもこれ、bash で自分から呼んでいます。
Claude Code がその hook を呼ぶかどうかは、この方法では一度も確かめていません。
判定テストが見ているもの : スクリプトの中身が正しいか
判定テストが見ていないもの : Claude Code がそのスクリプトを呼ぶか
中身が100点でも、置き場所や登録を間違えていれば呼ばれません。
そして呼ばれなかったとき、Claude Code は何も言いません。 コマンドがそのまま通るだけです。
「止まらなかった」と「止める仕組みが最初から動いていなかった」は、画面上まったく同じに見えます。
失敗が無音なのが厄介なところ
公式ドキュメントに書いてある挙動として、hook が異常終了したりタイムアウトしたりしても、
判断を出さずに操作はそのまま進みます。 安全側には倒れません。
つまり hook は、こういう性質を持っています。
- 動いていれば止まる
- 動いていなくても、動いているときと見分けがつかない
パスを1文字打ち間違えた。実行権限を付け忘れた。JSON のカンマがずれていた。
どれも、事故が起きるまで気づけません。
だから「止まるか」のテストとは別に、「そもそも呼ばれる状態か」のチェックが要ります。
設置で外れるのは5か所だった
自分で潰していったら、5つに落ち着きました。上から順に、外れやすい順です。
1. 実行権限
chmod +x .claude/hooks/deny-dangerous.sh
ここが一番ひっかかりました。 bash deny-dangerous.sh はファイルに実行権限が無くても動きます。
だから手元のテストは通る。でも Claude Code はコマンドとして実行するので、権限が無いと呼べません。
「テストは通るのに動かない」の典型がこれです。
2. settings.json があるか
プロジェクト直下の .claude/settings.json です。無ければ何も起きません。
3. JSON が壊れていないか
python3 -c "import json,io,sys; json.load(io.open(sys.argv[1],encoding='utf-8'))" .claude/settings.json
カンマ1つで壊れます。壊れていると設定ごと読まれません。
deny も一緒に効かなくなるので、ここは黙って全部無効になります。
4. hook が登録されているか
settings.json を眺めて目視で確認、をやめました。見落とすので。
PreToolUse の中の command を実際に取り出します。
python3 - .claude/settings.json <<'PY'
import json, io, sys
d = json.load(io.open(sys.argv[1], encoding="utf-8"))
for group in d.get("hooks", {}).get("PreToolUse", []):
for h in group.get("hooks", []):
if h.get("type") == "command" and h.get("command"):
print(h["command"])
PY
何も出てこなければ、登録できていません。
5. そのパスに、ファイルが本当にあるか
4で取り出したパスには ${CLAUDE_PROJECT_DIR} が入っています。展開してから実在を見ます。
resolved=$(printf '%s' "$hook_cmd" | sed "s|\${CLAUDE_PROJECT_DIR}|$(pwd)|g")
[ -f "$resolved" ] && echo "ある" || echo "ない"
4が通って5で落ちるパターンがあります。 登録はしたが、ファイルを別の場所に置いた、という形です。
これを流すと、こう出ます。
==============================================
設置チェック
==============================================
[OK] deny-dangerous.sh に実行権限がある
[OK] settings.json がある
[OK] settings.json の JSON が壊れていない
[OK] settings.json に PreToolUse hook が登録されている
[OK] 登録されているパスにファイルが実在する
「問題なし」と書いてはいけない場所がある
冒頭の話に戻ります。
最初のスクリプトは、設置チェックを省略したときも最後にこう出していました。
設置チェック: 問題なし
確かめていないのに「問題なし」。 教材のフォルダで流したときは省略されるので、
そこだけ読むと「合格した」に見えます。
直したのはこれだけです。
if [ "$install_ng" -ne 0 ]; then
printf ' 設置チェック: NG %d 件 ← 先にこちらを直してください\n' "$install_ng"
elif [ "$install_skipped" -ne 0 ]; then
echo " 設置チェック: 未実施(.claude/hooks/ に置いてから再実行してください)"
else
echo " 設置チェック: 問題なし"
fi
「OK」と「NG」の2択にしていたのが間違いでした。 3つ目の状態があった。「まだ見ていない」です。
安全装置のテストで、これは一番出してはいけない表示だと思います。
見ていないものを、合格と読ませてしまうので。
止まることだけ見ていると足りない
もう1つ、テストを書いていて考えが変わったところがあります。
最初は「危険なコマンドが止まるか」だけ並べていました。足りません。
止めすぎたときのほうが、実は先に困ります。
いま通ってほしい側を18件持っています。
[OK] PASS npm run build
[OK] PASS npm test
[OK] PASS git status
[OK] PASS git add .
[OK] PASS git commit -m "fix typo"
[OK] PASS git pull
[OK] PASS rm old.txt
[OK] PASS git commit -m "add push button"
[OK] PASS npx create-next-app my-app
git commit -m "add push button" を入れているのは、雑に push の3文字だけ探すと当たるからです。
$ printf '%s' 'git commit -m "add push button"' | grep -q "push" && echo 当たる
当たる
コマンド名の位置を見ずに単語だけ探すと、こうなります。 だから境界を決めて、
「コマンドとして実行される位置に来たときだけ」当てるようにしています。
rm old.txt もわざと通しています。rm を全部止めると、まともに作業が進みません。
止めているのは -r -f が付く形だけです。
止まらないのと同じくらい、止まりすぎも困ります。 使えないので、そのうち hook を外します。
外したら意味がありません。
誤検知は仕様として受け入れる
hook はコマンド文字列全体を見ています。だから、危険な単語を含むだけのコマンドも止まります。
[OK] DENY echo "git push を禁止する" >> README.md
これはバグではなく、そう作りました。取りこぼすより誤検知する側に倒しています。
この記事を書いている間に、3回止められた
机上の話にしたくないので、実際に起きたことを書きます。全部この記事の執筆中です。
1回目。 検証用に作った一時ディレクトリを消そうとしました。
rm -rf .../scratchpad/proj
→ rm の再帰/強制削除は claude-ops のローカル hook で止めています。
本当にただの一時ディレクトリでした。このときは正直、邪魔でした。
別の名前で作り直して進めました。それで足りました。
2回目と3回目。 上で書いた「push の3文字だけ探すと当たる」を確かめようとしたときです。
検証コマンドの中に文字列 git push が入っていたので、検証そのものが止まりました。
→ git push は claude-ops のローカル hook で止めています。
何も push しません。grep に渡す文字列として書いただけです。でも hook は文字の並びしか見ていない。
仕様どおりの動作です。
結局、文字列を組み立てて回避しました。
G=$(printf 'g%sit' ''); P=$(printf 'pu%ssh' '')
printf '%s' "$G commit -m \"add $P button\"" | grep -q "$P" && echo 当たる
これは hook の穴でもあります。 同じやり方で、止めたいコマンドも組み立てられる。
1本目の記事に書いたとおり、文字列を見る方式で塞ぎきることはできません。
それでも入れているのは、事故のほとんどが「悪意なく普通に打ったコマンド」だからです。
回避しようとしている人は止められません。うっかりは止まります。
止まったら Write / Edit ツールで書かせるか、今回のように書き方を変えれば進みます。
3回とも、数十秒で回避できました。 その程度の代償だと思っています。
防げないこと
正直に書きます。
hook が落ちれば素通りします。 前述のとおり安全側に倒れません。
settings.json の deny と両方置く前提で考えたほうが安全です。
スクリプトファイルに書いてから実行する形は止まりません。 実行される文字列は
「そのファイルを動かす」だけになるので、中身までは見ていません。
設置チェックの5項目を全部通しても、「Claude Code が実際に呼んだ」ことの確認にはなっていません。
呼ばれる条件が揃っていることまでしか見ていない。ここは正直、まだ埋まっていません。
確実なのは、この記事の最後にやったように実際に止まるコマンドを1回打ってみることです。
Windows では確認していません。 シェルスクリプトは動きません。
まとめ
- 判定テストは「中身が正しいか」しか見ていない。 呼ばれるかは別
- hook の失敗は無音。動いていないことに、事故まで気づけない
- 設置で外れるのは5か所。実行権限が一番多い(テストは通るのに動かない)
- 確かめていないものを「問題なし」と表示しない。 3つ目の状態は「未実施」
- 止まるテストと同じ数だけ、通るテストを書く。 止めすぎると外したくなる
- 最後は実際に1回止めてみるのが確実
自分は文系の大学生で、プログラミングは得意ではありません。
得意でないぶん、「たぶん動いている」を残しておくと後で困るので、
確かめる側を先に厚くしました。
確かめていないことは、確かめていないと書きました。