— Insights
Claude Code hooks で「AI に守らせる」品質ゲートを作る
CLAUDE.md に「〜せよ」と書くだけでは AI は確率的にしか守りません。Claude Code の hook を使えば、commit 前 lint / test や push 前 AI レビューを決定論的に強制できます。実運用で踏んだ 4 つの罠(exit 1 の罠 / watch モード無限ハング / cwd 依存バグ / commit メッセージ誤検知)とその設計解を共有します。
Claude Code を使って開発していると、いつの間にか同じ規約違反を何度も指摘するはめになります。「secret はコミットしない」「push 前に typecheck を通す」「format は保存時に走らせる」。CLAUDE.md にはっきり書いてあるのに、忙しい局面で見落とされる。あるいは要約タイミングで規約の一部が context から欠落する。人間相手ならレビューでカバーできる話ですが、AI 相手だと 「書いても守られないなら書くだけ無駄」 の感覚が確実に強くなっていきます。
CLAUDE.md や skill に書いた「〜せよ」は、AI にとってはあくまでも参考情報です。守る/守らないは確率の問題として現れます。これに対して Claude Code の hook は、ライフサイクル上の特定イベントで 必ず シェルスクリプトなり subagent なりを走らせられる仕掛けです。しかも失敗を返せば tool 呼び出し自体をブロックできる。この違いは大きいです。
本稿では、自社プロジェクトで hook を運用して分かった「入れたつもりで実は何も守れていなかった」失敗と、その修正の設計を共有します。読み終わったときに手元の ~/.claude/settings.json を開いて、少なくとも 1 か所は書き直したくなる状態を目指します。
なぜ CLAUDE.md ではなく hook なのか
Claude Code は起動時に対象ディレクトリを起点にして CLAUDE.md チェーン(user global → 事業体 → リポ)を context に取り込みます。これは非常に強力ですが、あくまでも「AI に読ませて記憶させる」レベルの制約です。以下の状況で歯抜けが起きます。
- セッション長期化による要約圧縮。context 満杯の少し前に自動要約が走ると、優先度が低いと判定された規約は要約の中に埋もれ、参照精度が落ちます。
- タスクの熱量が上がった局面。「テストが失敗しているから急いで直したい」という文脈で、Claude は「push は禁止」の一文を見落としがちです。人間の焦りとほぼ同じ挙動をします。
- サブエージェント経由の実行。子タスクに渡した prompt には CLAUDE.md の全文は載りません。必要な規約は明示的に prompt に含めない限り効きません。
これを「AI の努力目標」で済ませずに、確実に効く決定論的な強制に持ち上げたい箇所があります。たとえば以下のようなものです。
- コミット直前の secret 検出
- push 前の typecheck / lint / test
- 危険コマンド(
rm -rf /、git push --forceなど)のブロック - ファイル編集後の自動 format
- push 前の AI コードレビュー(発展的な用法)
hook は Claude Code のライフサイクルイベント(PreToolUse、PostToolUse、Stop など)にフックして任意のコマンドを実行し、非ゼロを返せば tool 実行そのものを止められます。したがって「規約に書いてもう祈る」から「破ろうとしたら物理的に止まる」に運用がジャンプします。
最小構成の落とし穴: exit code の仕様
最初にはまるのがここです。Claude Code の hook は、exit 2 を返した時にだけ stderr が Claude にフィードされ、tool 呼び出しがブロックされる仕様です。素直に exit 1 を書くと、Claude 側は「何かエラーが出たけど tool 呼び出しは通っている」と判定して素通しします。私は最初にこの仕様を読み飛ばしていたせいで、git push --force を止めるつもりで書いた dangerous-block スクリプトが数週間まったく機能していませんでした。以下は正しい形の抜粋です。
#!/usr/bin/env bash
# dangerous-block.sh — 危険コマンドをブロックする hook (PreToolUse, Bash matcher)
set -euo pipefail
# stdin から Claude Code が渡す tool_input.command を取り出す
COMMAND="$(jq -r '.tool_input.command // ""')"
DANGEROUS_PATTERNS=(
'rm -rf /'
'rm -rf ~'
'sudo rm'
'git push --force'
'git reset --hard'
'git clean -fdx'
'chmod -R 777'
'dd of=/dev/'
'mkfs\.'
)
for pat in "${DANGEROUS_PATTERNS[@]}"; do
if [[ "$COMMAND" =~ $pat ]]; then
echo "🚫 danger: '$pat' に一致しました。中断します。" >&2
# Claude Code の仕様上、ブロックさせたいときは exit 2 を返す
exit 2
fi
done
exit 0
これを ~/.claude/settings.json の PreToolUse に matcher: "Bash" で紐付けると、Claude が Bash tool 経由で git push --force main を叩いた瞬間、stderr の内容付きでブロックされ、Claude 自身が「別の方針で行きます」と再検討を始めます。exit 1 は同じシチュエーションで素通ししてしまうので、hook 内のブロック判定はすべて exit 2 に統一するのが安全です。
2 つ目の落とし穴: watch モードで無限ハングする
pre-commit で pnpm test を走らせるのはよくある構成ですが、そのまま書くと セッションが返ってこなくなります。vitest や jest はデフォルトで watch モードに入ることがあり、hook 内で子プロセスとして起動されるとタスク完了を返さないまま滞留します。Stop hook 側ならセッションのハングにつながり、PreToolUse 側なら次の tool 呼び出しが永久に来ません。
解は 2 段構えです。CI=true を注入して watch を無効化し、さらに perl -e 'alarm 300; exec @ARGV' のような外側タイムアウトで最終保険をかけます。
# pre-commit-check.sh (抜粋) — テスト系の呼び出し
# CI=true を渡すと vitest / jest / playwright が run モードに落ちる
# perl の alarm でランナー自体が無反応でも 300 秒で必ず戻る
if [[ -f package.json ]] && command -v pnpm >/dev/null 2>&1; then
if jq -e '.scripts.test' package.json >/dev/null; then
CI=true perl -e 'alarm 300; exec @ARGV' pnpm test || {
echo "❌ pnpm test が失敗またはタイムアウトしました" >&2
exit 2
}
fi
fi
command -v で存在確認をしているのも重要です。CI 環境と dev 環境で pnpm / npm / yarn が混在するリポでも、hook 側で拾い分けられます。開発者が別リポに移った瞬間に hook が「pnpm がないので失敗」と誤爆すると、決定論ゲートの信頼が一気に壊れます。
3 つ目の落とし穴: hook の cwd が編集対象と一致しない
PostToolUse で biome format --write を走らせている hook が「動いているつもりで何もしていなかった」ことに気づいたのは、format 済みの状態を期待して push した直後、CI で lint が落ちたときでした。原因は hook が実行される cwd が、編集対象ファイルの git root と一致していなかった ことです。Claude Code は複数ディレクトリを跨ぐ操作ができるため、tool 呼び出しごとに cwd が変わり得ます。format コマンドが単に「その cwd で biome format --write <file_path> を実行」しているだけだと、リポの biome.json が読み込まれず、format が effectively no-op になります。
対処は file_path から git root を逆引きするだけです。
# post-edit-format.sh (抜粋) — 編集ファイルから git root を辿る
FILE="$(jq -r '.tool_input.file_path // ""')"
[[ -z "$FILE" ]] && exit 0
# 対象ファイルの親から git root を取得。見つからなければ何もしない。
REPO_ROOT="$(cd "$(dirname "$FILE")" && git rev-parse --show-toplevel 2>/dev/null || true)"
[[ -z "$REPO_ROOT" ]] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.json|*.jsonc)
if [[ -f "$REPO_ROOT/biome.json" ]]; then
(cd "$REPO_ROOT" && pnpm biome format --write "$FILE") || true
else
(cd "$REPO_ROOT" && npx --no-install prettier --write "$FILE") || true
fi
;;
esac
# format 失敗はサイレント無視。編集フローを止めない。
exit 0
hook はこの手の「効いていないけどエラーも出ない」状態が一番怖いので、時々 set -x を有効にして実際の実行ログを確認するようにしています。「format が走っている」という前提が崩れると、pre-commit で lint を落とす場面が急に増えて開発リズムが壊れます。
4 つ目の落とし穴: コマンド境界の誤検知
dangerous-block を導入してすぐに踏んだ、地味ですが影響の大きい罠がこれです。git push --force を含むコミットメッセージで commit したいシーンで、hook が message 内の文字列を検出してブロックしてしまう問題が起きました。dangerous-block のようなドキュメント記事について commit しようとすると発生します。
原因は正規表現マッチが単純な substring になっていたことです。git commit -m "hook で git push --force を止める方法" の中の git push --force にも反応してしまいます。ここは 単語境界と先頭一致を明示するだけで済みます。
# 修正版: コマンドの先頭で始まる形にのみ反応する
DANGEROUS_PATTERNS=(
'(^|;|&&|\|\|)[[:space:]]*rm -rf /'
'(^|;|&&|\|\|)[[:space:]]*git push[[:space:]]+.*--force'
'(^|;|&&|\|\|)[[:space:]]*git reset[[:space:]]+--hard'
)
さらに一段安全にするなら、git commit -m 経由で渡されるメッセージそのものはブロック対象から除外するホワイトリストを噛ませます。「commit メッセージ内の危険コマンド文字列」は実行されないので、そもそも判定対象にすべきではないためです。
応用: push 前レビューを agent hook で強制する
決定論ゲート(commit 前)と AI レビュー(push 前)は目的が違います。commit 前は「secret や lint 崩壊など、機械的に判定できる項目」を確実に止めるためのもの。push 前レビューは「バグやセキュリティ上の落ち度など、LLM でしか拾えない観点」を軽く見張る役目です。Claude Code の experimental な agent hook を使うと、git push 直前に subagent を起動し、差分をレビューさせて重大な問題があれば push そのものをブロックできます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git push*)",
"hooks": [
{
"type": "agent",
"prompt": "現在のブランチと origin の差分を code-reviewer 観点でレビューしてください。以下の重大な観点で明白な問題があれば {\"ok\": false, \"reason\": \"...\"} を返します。それ以外は {\"ok\": true} を返してください。\n\n観点: (1) 平文の secret や credentials、(2) 認証・認可の欠落、(3) SQL injection / XSS / SSRF、(4) 明白なクラッシュを招くバグ。\n\n**advisory な指摘は返さない**ことを厳守してください。false positive はレビュー機能への信頼を壊します。\n\ncommit メッセージに [skip-review] が含まれる場合は無条件に {\"ok\": true} を返してください。"
}
]
}
]
}
}
ここで詰まりやすいのが subagent の権限です。差分をレビューするために subagent は git log / git diff / git show あたりを実行できる必要があります。これらが permissions.allow に入っていないと、subagent は「差分が取得できず評価不能」で ok: false を返し、全 push が止まります(fail-closed)。この挙動自体は正しくて、「評価不能なら素通し」にすると AI レビューという仕組みが機能しなくなるので、~/.claude/settings.json の user global で読み取り系 git を明示的に allow するのが正解です。
{
"permissions": {
"allow": [
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(git status:*)",
"Bash(git rev-parse:*)"
]
}
}
もう一つ、LLM レビューは原則 advisory、block は重大なもののみに絞る運用が重要です。誤検知が続くと開発者は [skip-review] を条件反射で付けるようになり、レビュー機能そのものが空文化します。参考になるのは Cloudflare が公開した実運用データで、同社は AI コードレビューを全社展開した最初の 30 日間(48,095 MR / 131,246 レビュー実行)で 平均 1.2 findings/review まで指摘を絞り込み、開発者による手動オーバーライドを 0.6% に抑えています。閾値設計は保守寄りに、確信度が高い項目だけを block、それ以外は「気付き」として残す設計にしておきます。
運用上のちょっとしたコツ
hook は書いて満足するのではなく、壊れていないか定期的に確認する必要があります。私が習慣にしているものを 3 つ挙げます。
hook を vault などのバージョン管理下に置く。 公式ドキュメントの例は各プロジェクトの .claude/hooks/ にスクリプトを置く形ですが、私は複数マシン間で復元したい・履歴を追いたい理由から、自分の Obsidian vault の .claude/hooks/ に集約し、~/.claude/settings.json からは絶対パスで参照しています。「hook を消したり書き換えたのはいつのどの変更か」を後から追える意義は思ったより大きいです。
動作確認は「意図的に破ってみる」形で行う。 「hook が入っているから安全」の感覚は事故の温床です。四半期に一度でよいので、意図的に git push --force origin main を Claude 経由で試みる、テストを 1 個わざと失敗させて git commit を試みる、といった破壊試験を回します。壊れていない hook より、壊れていることに気づいていない hook の方が危険です。
[skip-review] の使用回数をモニタする。 意図的な逃げ道は必要ですが、使用頻度が上がったら「hook が過剰か、レビュー精度が低い」のシグナルです。私は git log --oneline を週次で眺めて、[skip-review] の頻度が上振れしていないか確認します。3 回連続で使ったら hook 側を疑うのがルールです。
まとめ
「AI に規約を守らせる」ためのレイヤは、CLAUDE.md(哲学と説明)、skill(手続きの再利用)、hook(決定論的な強制)の 3 層で考えると整理されます。CLAUDE.md に「〜せよ」と書くだけで済ませたくなりますが、それは確率的な遵守にとどまります。壊されて困る規約は hook に落とす のが基本原則です。そして hook 自体は書いて終わりではなく、exit 2 の徹底、CI モードとタイムアウトの併用、cwd の明示的な扱い、コマンド境界の厳密化、といった細部の設計に気を配って初めて実効性が出ます。
commit 前の決定論ゲートと push 前の LLM レビューを組み合わせれば、機械的な事故を止めつつ、AI の目でも見張られている状態を作れます。experimental な機能を絡めるので開発リズムを見ながらの調整は必要ですが、少なくとも「規約を書いたけど守られなかった」という敗北感からは解放されます。手元の hook を、今日 1 か所だけでも見直してみてください。