Claude Codeを業務で回していると、生成の速さよりも「触ってほしくないファイルを触られたときのコスト」の方が問題になってくる。今回は、その対処に使っているhooks(フック)の使い方を、実際にこのブログの運用リポジトリで動いている設定をそのまま材料にして書く。
先に結論を書くと、hooksは「エージェントの判断を賢くする仕組み」ではなく、判断の手前に置く機械的な関門として設計するのが効いた。プロンプトやCLAUDE.mdに「このファイルは変更しないでください」と書く運用も併用しているが、それだけでは守れなかった。
hooksとは何か、プロンプトでの禁止と何が違うのか
Claude Codeのhooksは、エージェントがツールを実行する前後に任意のコマンドを差し込める仕組みだ。.claude/settings.jsonにイベント名とマッチャ、実行するコマンドを書くと、条件に合うツール実行のたびにそのコマンドが呼ばれる。
プロンプトでの禁止と決定的に違うのは、判断がモデルの外側にある点だ。CLAUDE.mdに書いたルールは、コンテキストが長くなるほど相対的に薄まるし、「今回は例外だろう」という推論の余地が残る。一方フックは、ツール実行のたびに毎回同じコードが走り、終了コードで通す/止めるが決まる。文脈の長さにも、その場の判断にも左右されない。
私の場合、この差が実害として出たことがあった。運用ループの数値基準を書いたファイル(投稿頻度やエンゲージ率の閾値を決めているもの)を、基準を見直す専用の作業でないときに書き換えられてしまうと、後から「いつ誰の判断で変わったのか」が追えなくなる。CLAUDE.mdに注意書きを置いていたが、基準を参照しながら別の作業をしている最中に、ついでに直されるという形で通過した。
実際に動かしている設定:PreToolUseでWrite/Editを検査する
設定ファイルの中身はこれだけだ。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-loop-baseline.sh"
}
]
}
]
}
}
読み方は3点。
PreToolUse: ツールが実行される「前」に走る。ここで止めれば、ファイルは1バイトも書き換わらないmatcher: 対象のツール名を正規表現で指定する。Write|Editなので、ファイル作成と部分編集の両方を拾うcommand: 実行するコマンド。$CLAUDE_PROJECT_DIRが使えるので、リポジトリ内の相対位置で書ける
フック本体は標準入力でJSONを受け取る。ツール名・ファイルパス・引数などが入っているので、それを見て通すか止めるかを決める。
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/')
case "$TOOL_NAME" in
Write|Edit) ;;
*) exit 0 ;;
esac
matcherで絞っているのに本体でもツール名を見ているのは、フックを他のイベントへ流用したときに誤爆させないため。フック側を「どのイベントから呼ばれても正しく判断する」形にしておくと、設定の書き換えで壊れにくい。
終了コードの使い分けが挙動を決める
hooksで一番つまずきやすいのが終了コードの意味だ。実装しながら確かめた挙動は次のとおり。
exit 0: 通す。何もなかったように処理が続くexit 2: 止める。標準エラー出力に書いた内容がエージェントへ返る
つまり止めるときは、標準エラーに「なぜ止めたか」「どうすれば進めるか」を書くのが本質になる。ここを素っ気なくすると、エージェントは同じ操作を少しだけ変えて再試行してくる。私の実装ではこう書いている。
echo "基準ファイル(${FILE_PATH})はmeta-* prefixの専用worktree以外から変更できません。" >&2
echo "改定が必要な場合は、ユーザーの指示のもとmeta-*ワークツリーを新規に切ってください。" >&2
exit 2
2行目が効いている。禁止だけを伝えると迂回を試みるが、正しい進み方まで示すと、そちらへ切り替えるか、ユーザーに確認を上げてくる。フックのメッセージは、エラー文ではなく次の手順書として書くというのが、実際に回して得た結論だ。
判定条件は「ファイル」ではなく「どこから触っているか」で組む
守りたい対象を決めるとき、最初は対象ファイルの一覧だけで組もうとした。だがそれだと、正当な改定作業まで止まってしまう。そこで判定を2段にした。
FILE_PATH=$(echo "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
case "$FILE_PATH" in
*x/metrics-baseline.md|*threads/standards.md|*.claude/hooks/protect-loop-baseline.sh) ;;
*) exit 0 ;;
esac
CWD_BASENAME=$(basename "$(pwd)")
case "$CWD_BASENAME" in
meta-*) exit 0 ;;
*) # 止める
esac
1段目で対象ファイルかを見て、違えば即座に通す。2段目で作業ディレクトリ名の接頭辞を見る。このリポジトリでは並行作業をgit worktreeで分離していて、基準の改定専用のworktreeにはmeta-という接頭辞を付ける運用にしている。つまり「そのファイルを触ってよい文脈にいるか」をディレクトリ名で表明させ、フックはそれを検査している。
対象ファイルの一覧にフック自身(protect-loop-baseline.sh)を含めている点も意図がある。守る側のコードを普通の作業中に書き換えられると、関門そのものを外せてしまうためだ。
いきなり止めない:audit(記録だけ)から始める
もう1つ、別系統のフックをRead|Bashに掛けている。こちらは大きいファイルを丸ごと読む操作を検知するもので、設定はauditとredirectの2モードを持つ。
{
"version": 1,
"mode": "audit",
"min_lines": 350
}
今はauditで運用している。redirectにすると該当操作を拒否するが、auditでは記録だけして通す。新しいフックを入れるときは、まずauditで1〜2週間動かし、何が引っかかるかを見てから止める側へ倒すのが安全だった。理由は単純で、想定していなかった正当な操作が一定数引っかかるからだ。いきなり拒否にすると、フックが悪いのか作業が悪いのかの切り分けに時間を取られ、結局フックごと外すことになる。
止める対象から外している操作もある。テスト・ビルド・lintのように出力が大きくなるのが当然のコマンドは、どのモードでも助言だけにして拒否しない。フックが日常の開発を止め始めると、外されるので、止める範囲は「壊れたら復旧が面倒なもの」に絞るのが結局は長持ちする。
導入して変わったこと、変わらなかったこと
変わったのは、基準ファイルの変更が「意図した作業でしか起きない」状態になったことだ。変更履歴を見たとき、そこにあるコミットは全て基準改定を目的にしたものになる。いつ誰の判断で閾値が動いたかを追う手間がなくなった。
変わらなかったこともある。フックはファイルへの書き込みという行為しか見ていないので、内容の良し悪しは判断しない。meta-worktreeの中でなら、雑な変更もそのまま通る。品質側は引き続き検査スクリプトとレビューの仕事で、フックはあくまで「触る文脈が正しいか」だけを担保する関門だと割り切ったほうが設計が素直になる。
もう1つ、フックはエージェントの学習には寄与しない。同じセッションの中では止められた経験が文脈に残るが、次のセッションではまた同じ操作を試してくる。これは欠点というより性質で、だからこそ毎回機械的に走る意味があると考えている。
まとめ
Claude Code hooksを実運用に入れて分かったことを5点にまとめる。
- hooksはモデルの判断の外側に置く機械的な関門。CLAUDE.mdの注意書きとは守備範囲が違う
PreToolUse+matcherでツールを絞り、フック本体でもツール名を再確認しておくと壊れにくいexit 2で止めるときは、標準エラーに「なぜ」と「どう進めるか」を書く。手順書として書くと迂回されない- 判定はファイル名だけでなく「どの文脈から触っているか」(worktree名など)と組み合わせる
- 新規フックはaudit(記録だけ)で数週間動かしてから、止める側へ倒す
守りたい対象がはっきりしているなら、フックは数十行のシェルスクリプトで足りる。生成の速度を上げる工夫より、こちらの方が結果として作業のやり直しを減らした。