0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の hook、テストが全部OKでも動いている証明にはならない

0
Last updated at Posted at 2026-09-01

安全用の 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.jsondeny と両方置く前提で考えたほうが安全です。

スクリプトファイルに書いてから実行する形は止まりません。 実行される文字列は
「そのファイルを動かす」だけになるので、中身までは見ていません。

設置チェックの5項目を全部通しても、「Claude Code が実際に呼んだ」ことの確認にはなっていません。
呼ばれる条件が揃っていることまでしか見ていない。ここは正直、まだ埋まっていません。
確実なのは、この記事の最後にやったように実際に止まるコマンドを1回打ってみることです。

Windows では確認していません。 シェルスクリプトは動きません。

まとめ

  • 判定テストは「中身が正しいか」しか見ていない。 呼ばれるかは別
  • hook の失敗は無音。動いていないことに、事故まで気づけない
  • 設置で外れるのは5か所。実行権限が一番多い(テストは通るのに動かない)
  • 確かめていないものを「問題なし」と表示しない。 3つ目の状態は「未実施」
  • 止まるテストと同じ数だけ、通るテストを書く。 止めすぎると外したくなる
  • 最後は実際に1回止めてみるのが確実

自分は文系の大学生で、プログラミングは得意ではありません。
得意でないぶん、「たぶん動いている」を残しておくと後で困るので、
確かめる側を先に厚くしました。

確かめていないことは、確かめていないと書きました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?