TL;DR
- Claude Code hooks の用途は「品質担保」「監査」「安全制御」の3つ。
- 配置はライフサイクルとCIとの責務分担で決まる。
- 「全部hooksに詰める」は失敗パターン。CIで代替できないものだけ置く。
はじめに
この記事はシリーズ「AIコーディング導入のセキュリティ設計」の L3 実行時制御層 の深掘りです。 👉 AIコーディング導入のセキュリティ設計 4層モデルで攻撃面を整理する
hooks は便利すぎるがゆえに「とりあえず入れる」が起きやすく、hooksが壊れるとCIも壊れる単一障害点を作りがちです。用途を3つに分けて整理します。リポジトリ側の設計は AIが迷わないリポジトリ設計 も参照。
0. hooks / subagents / CI の責務比較
hooks の前に、似たレイヤーが3つあることを理解しておくと配置を間違えません。
| 層 | 役割 | タイミング | 壊れた時の影響範囲 |
|---|---|---|---|
| subagents | 文脈分離・専門化 | エージェントが自発的に呼ぶ | 特定エージェント内 |
| hooks | 強制介入・決定論 | tool実行/編集の前後に自動発火 | Claude Code 全体 |
| CI | 最終ガード・全開発者共通 | push / PR時 | チーム全員 |
subagents は「判断をずらす」、hooks は「判断を止める/記録する」、CI は「人間にマージさせない」。hooks はこの3層の中で 唯一リアルタイムに割り込める層 で、そのぶん 壊れた時の爆発半径も中くらい です。subagents 設計は Claude Code subagents の実運用パターン、CI との境界は後述。
1. hooks の3用途
| 用途 | 仕込む場所 | 例 | CIで代替 |
|---|---|---|---|
| 品質担保 | PostToolUse (matcher: Edit|Write|MultiEdit) / Git の pre-commit | format / lint / typecheck | ✅ 可能 |
| 監査 | PostToolUse | tool呼び出しログをSIEMへ転送 | △ 部分的 |
| 安全制御 | PreToolUse | 危険コマンド・本番接続のブロック | ❌ 不可 |
本記事のイベント名は Claude Code の公式仕様(2026-04 時点)に合わせ PascalCase で統一します。「編集後に format」は
PostToolUse+ ツール matcher(Edit/Write/MultiEdit)で実装します。pre-commitは Git 側の hook で Claude Code とは別経路。両者は役割が近いので併用されますが、混同しないよう表記を分けます。
品質担保はCIでも回せます。hooksは試行回数削減用で、最終判定はCIに任せる。監査は引数粒度で取るためhooksが有利。安全制御は PreToolUse でしかタイミングが取れない最重要用途。
各用途でよくあるアンチパターンは次の通り:
- 品質担保: 「CIで見ているから hook では fail させずログだけ」→ 開発者が気づかず無限にlint違反が増える
- 監査: 「同期POST」→ SIEM 不調時に Claude Code 全体が固まる
- 安全制御: 「ブラックリストで禁止コマンド検出」→ 簡単にバイパスされる。必ず allowlist。
2. ライフサイクルとイベント一覧
Claude Code のhookイベントは pre/post-tool-use 以外にもあります。用途に合わせて正しいイベントを選ぶことが、配置ミスを減らす最短路です。
| イベント | 発火タイミング | 主用途 | 返り値で割り込めるか |
|---|---|---|---|
UserPromptSubmit | ユーザー入力送信直後 | 入力のサニタイズ・PII 検出・プロンプト拡張 | ✅ 入力改変・ブロック可 |
PreToolUse | tool 呼び出し直前 | 安全制御(最重要) | ✅ decision:"block" でブロック可 |
PostToolUse | tool 呼び出し直後 | 監査・軽い副作用、Edit/Write matcher 指定で品質担保 | ❌(ログ目的) |
Stop | エージェントの応答完了時 | セッション終了ログ・タスク集計 | ❌ |
SubagentStop | subagent 応答完了時 | subagent ごとの結果検査 | ❌ |
Notification | 通知(権限要求・idle など) | Slack 連携・不在検知 | ❌ |
判断順序は「入力に手を入れたいか → UserPromptSubmit / 実行を止めたいか → PreToolUse / 記録だけか → PostToolUse / Stop」。ここを取り違えると、「止めたいのに PostToolUse に置いて手遅れ」「記録だけでいいのに PreToolUse に置いて遅延」が起きます。
3. 配置判断
判断フローは「止めたいか / 記録したいか / 整えたいか」の3択。
flowchart TD
A[このチェックは何のため?] --> B{実行を止めたい?}
B -- yes --> C[PreToolUse / UserPromptSubmit]
B -- no --> D{記録したい?}
D -- yes --> E[PostToolUse / Stop 非同期]
D -- no --> F{コード/ファイルを整えたい?}
F -- yes --> G[PostToolUse matcher=Edit/Writeor Git pre-commit]
F -- no --> H[hookに置かずCIへ]
やってはいけない配置:
PreToolUseに重い処理(数秒以上)を入れる → 体感速度が破壊されるPostToolUseを同期処理にする → ログ送信詰まりでtoolが止まる- 1つのhookに複数用途を混ぜる → デバッグが地獄
- hookスクリプトでさらに Claude Code を呼び出す → 再帰で固まる
4. 実装パターン
安全制御: 本番DB接続のブロック
Claude Code の PreToolUse hook は stdin で hook_event_name / tool_name / tool_input を受け取り、stdout に {"decision":"block","reason":...} を返すとブロックできます(公式仕様)。判定は 正規表現によるブラックリスト検出ではなく、接続先ホストの allowlist で行います。ブラックリストは環境変数展開・SSH トンネル・postgres:// URI 形式・別名ホストなどで容易にバイパスされるためです。
⚠ **以下は設計思想を示すサンプルです。本番投入する前に必ずレビューと実機テストを行ってください。**DSN の厳密なパースはシェルでは難しいため、本番では Python/Node の DSN パーサを呼び出す形を推奨します(コメント末尾に最小例を添えます)。
grepの単純一致はコマンド文字列中にlocalhostなどが偶然現れただけで通過してしまい、誤った安全感を生みます。
#!/usr/bin/env bash
# Claude Code PreToolUse hook: Bash tool 実行前に接続先を検査
set -u
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# Bash 以外は素通し(他 tool を誤ブロックしない)
[ "$tool_name" != "Bash" ] && { echo '{}' ; exit 0 ; }
# DB クライアントを含むコマンドだけを対象にする
case "$command" in
*psql*|*mysql*|*pg_dump*|*postgres://*|*mysql://*) ;;
*) echo '{}' ; exit 0 ;;
esac
# 接続先ホスト(allowlist)。カンマ区切り。
ALLOWED_HOSTS="${DB_ALLOWED_HOSTS:-localhost,127.0.0.1,db.staging.internal}"
# ---- host 抽出(粗めだが grep 総当たりよりは確度が高い)----
# 1) -h HOST / --host=HOST / --host HOST
# 2) postgres://USER:PASS@HOST:PORT/DB / mysql://...
# 3) 未指定なら PGHOST / MYSQL_HOST 環境変数
hosts=""
hosts+=" $(echo "$command" | grep -oE -- '-h[ =]?[^ ]+' | sed -E 's/^-h[ =]?//')"
hosts+=" $(echo "$command" | grep -oE -- '--host[ =][^ ]+' | sed -E 's/^--host[ =]//')"
hosts+=" $(echo "$command" | grep -oE '(postgres|mysql)://[^ ]+' \
| sed -E 's#^(postgres|mysql)://##; s#^[^@]*@##; s#[:/].*$##')"
# host 指定がゼロなら環境変数フォールバック
if [ -z "$(echo "$hosts" | tr -d ' ')" ]; then
hosts="${PGHOST:-} ${MYSQL_HOST:-}"
fi
# 判定: 抽出したホストがすべて allowlist に入っていればOK
deny_reason=""
for h in $hosts; do
[ -z "$h" ] && continue
matched=0
for a in $(echo "$ALLOWED_HOSTS" | tr ',' ' '); do
[ "$h" = "$a" ] && matched=1 && break
done
[ "$matched" = "0" ] && deny_reason="not in allowlist: $h" && break
done
# 判定不能(ホストが抽出できなかった)= fail-closed でブロック
if [ -z "$(echo "$hosts" | tr -d ' ')" ]; then
deny_reason="host を特定できずfail-closed"
fi
if [ -n "$deny_reason" ]; then
jq -nc --arg reason "本番相当のDB接続は禁止 ($deny_reason)。staging を使用してください。" \
'{decision:"block", reason:$reason}'
exit 0
fi
echo '{}'
ポイント:
stdoutに JSON を返す(stderr・exit 1でのブロックは非推奨、公式ではdecision:"block"が正)tool_nameがBash以外なら素通し(他 tool を誤ブロックしない)- ホストは文字列一致ではなく「抽出してから厳密等価」で判定。
grep -Fの単純一致は簡単にバイパスされるため使わない - 判定不能なら素通しではなくブロック(fail-closed)
- 本番では
python -c 'import urllib.parse,sys; print(urllib.parse.urlparse(sys.argv[1]).hostname)' "$url"のような DSN パーサを挟み、IDN・IPv6・別名にも追従させる - allowlist に登録する値は正規化した hostname のみ。IP と FQDN が混在する運用はレビュー負荷を上げるので避ける
監査: 非同期ログ送信(ローカル退避 → 別プロセス転送)
#!/usr/bin/env bash
# 同期でローカルに追記(ロスト回避)→ 別プロセスで SIEM へ転送
input=$(cat)
LOG_DIR="${CC_AUDIT_DIR:-$HOME/.claude/audit}"
mkdir -p "$LOG_DIR"
echo "$input" >> "$LOG_DIR/$(date +%Y-%m-%d).ndjson"
( curl -sS --max-time 1 -X POST -d @- "https://siem.internal/ingest" < "$LOG_DIR/$(date +%Y-%m-%d).ndjson" >/dev/null 2>&1 || true ) &
echo '{}'
まず ローカル ndjson に同期追記して監査ログを守り、SIEM 転送は & で非同期+失敗しても継続。curl --max-time 1 でタイムアウトしてもログ本体は消えません。SIEM 側のリトライワーカー(cron / launchd など)で未送信ファイルを定期回収する運用が安全です。監査設計の全体像は AIガバナンスの権限マトリクス を参照。
品質担保: 編集後の自動format(CI併用)
PostToolUse で matcher を Edit|Write|MultiEdit に絞り、編集対象に対してのみ pnpm prettier --write を走らせます。CI側でも pnpm format:check を回して、hookが壊れても最終ガードが効く構成にします。Git の pre-commit との二重化は好みに応じて併用してください。
L1(権限層)との役割分担に迷ったら、シリーズ最初に戻ってください。 👉 MCP権限設計の判断軸 allowlist粒度とscope命名から組織導入まで
5. CIとの責務分担
「hooks を入れたら CI はいらないのでは」と聞かれますが、両方必要です。役割が違います。
| 観点 | hooks | CI |
|---|---|---|
| 目的 | 開発中の試行回数削減 | マージ前の最終ガード |
| 実行環境 | ローカル(個人PC) | 共通(再現性あり) |
| 速度 | ミリ秒〜数秒が限界 | 数分までは許容 |
| 失敗時 | 開発者のみ影響 | チーム全員が止まる |
| 回避 | 無効化しやすい | 管理者以外は回避不可 |
原則は「hooks で早く気付かせる、CI で必ず止める」。同じチェックを hooks と CI 双方に置いて問題ありません。むしろ二重化が安全です。例として typecheck を並べると:
- post-edit hook: 変更ファイルだけ
tsc --noEmit -p tsconfig.partial.jsonで 1〜2 秒 - CI: 全体で
tsc --noEmitを 30〜60 秒かけて実行
hook で通った any 流入を CI で止める、という役割分担が成立します。逆に hooks でしか間に合わないもの(本番DB 接続ブロックなど)は CI には置けません。「CI で代替できるか」が hooks 不採用の判断基準になります。
6. 失敗パターンと復旧
hooks の怖さは「hooks 自体が壊れると Claude Code が何も動かなくなる」点です。実際に起きる失敗と復旧手順を先に知っておきます。
失敗1: hook が無限ループ
hook 内で claude コマンドや自分自身を呼び出すと再帰します。復旧は ~/.claude/settings.json の hooks セクションを一時的に空配列にして再起動。
失敗2: hook が数十秒返ってこない
PreToolUse で重い処理を入れた場合に発生します。ユーザー体験は「AIが固まった」になり、業務停止に近い。対策:
PreToolUseにはタイムアウト(例:timeout 2sを wrap)を必ず付ける- 判定に必要な外部I/Oは事前キャッシュ
- 判断不能時は素通しにするかfail-closedにするかをチームで合意
失敗3: 誤ブロックで正当な作業が止まる
allowlist が厳しすぎて staging ですら動かない、というケース。復旧は hook の環境変数で bypass フラグを用意しておくのが現実的です。
[ "$CC_HOOKS_BYPASS" = "1" ] && { echo '{}' ; exit 0 ; }
bypass フラグの使用は PostToolUse で別ログに記録し、後で監査できるようにします。
段階導入の推奨順序:
- 監査 hook(PostToolUse で非同期ログ)だけ入れる → 1週間様子見
- 品質担保 hook(post-edit の format)を追加 → CI と二重化
- 安全制御 hook(PreToolUse の allowlist)を追加 → bypass 経路を用意してから本番
いきなり PreToolUse でブロックを入れると、「Claude Code が突然動かなくなった」という問い合わせがチームから殺到します。監査 → 品質 → 安全制御 の順が安全です。
まとめ
- hooks は UserPromptSubmit / PreToolUse / PostToolUse / Stop / SubagentStop / Notification の公式イベントから用途で選ぶ(編集後処理は
PostToolUse+ matcher) - 用途は「品質担保」「監査」「安全制御」の3つ。CIで代替できないものだけ hooks に置く
- hooks と CI は 二重化前提。早く気付く=hooks、必ず止める=CI
- 失敗したとき Claude Code 全体が止まるため、bypass 経路・段階導入 を先に設計しておく
配置に迷ったら、この記事の冒頭の責務比較表とフロー図に戻ってください。
ここで設計した hook を、承認境界の強制として OSS 化した実装が s977043/PlanGate です。10 種類の hook で「plan.md なしの production 編集」「C-3 承認なしの実装」「承認後の plan 改竄」などを runtime ブロックする運用は、PlanGate v8.6 Hookで承認境界を強制する で具体例を整理しています。
全体像に戻る: 親記事はこちら
