L2 ─ Hooks で自分を監視させる
L1 で型は作った。だが Claude は型を呼ばずに自前でやり始めることがある。CLAUDE.md に「絶対 X するな」と書いても忘れる。
L2 = Hooks はその対策。Claude の行動の前後に shell script を挟み、機械的にルールを強制する。
なぜ CLAUDE.md だけでは足りないのか
CLAUDE.md は LLM への指示だ。重要だが、確率的にしか守られない。長くなるほど忘れる、優先度の解釈がブレる、Recency Bias で末尾の指示が強く効く ─ これは LLM の本質的な性質で、完全には制御できない。
zenn で話題になった kazuph の記事「Claude Code のすぐルール忘れる問題を Hooks で解決する」 はこの問題を直球で扱っている:
CLAUDE.md にルールを書いても、長い conversation の途中で忘れる。Hook で会話履歴をパースして、ルール違反していないか別 LLM で検閲する。
これが Hooks の本懐 ── 「ソフトな指示」を「ハードな強制」に変換する仕組み。
Hook の 5 タイプ
公式 Hooks ドキュメント(日本語版あり)にある主要なイベント:
graph LR
S[Session開始] --> SS[SessionStart]
SS --> U[ユーザー入力]
U --> UPS[UserPromptSubmit]
UPS --> C[Claude 思考]
C --> PRE[PreToolUse]
PRE -->|allow| T[Tool 実行]
PRE -->|deny| Block[blocked]
T --> POST[PostToolUse]
POST --> C
C --> STOP[Stop]
STOP --> END[Session 終了]
style PRE fill:#fff4e1
style POST fill:#fff4e1
style UPS fill:#fff4e1
style STOP fill:#fff4e1
style SS fill:#fff4e1
| Hook | 発火タイミング | 主な用途 |
|---|---|---|
SessionStart | session 開始時 | コンテキスト inject、状態リセット |
UserPromptSubmit | ユーザー入力直前 | プロンプト整形、ルール再注入 |
PreToolUse | tool 実行前 | 危険コマンド block、permission 補強 |
PostToolUse | tool 実行後 | format / lint / test 自動実行 |
Stop | session 終了時 | 履歴検閲、ログ出力 |
設定の基本構造
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-bash.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
}
]
}
]
}
}
3 つの軸の組み合わせで設計する:
- イベント (
PreToolUse/PostToolUse/ …) - matcher(正規表現で tool 名を絞り込む。
Bash、Edit|Write、mcp__.*など) - command(実行する shell script)
zenn / biki の記事 はこの 3 軸の使い分けを丁寧に整理している。
実装例 1:危険コマンドを止める
PreToolUse で rm -rf / 系を block する。
.claude/hooks/check-bash.sh:
#!/bin/bash
# stdin から JSON を受け取る
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# 危険パターンを検出
if echo "$COMMAND" | grep -qE '(rm\s+-rf?\s+/|sudo\s+rm|>\s*/dev/sd|dd\s+if=.*of=/dev/)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by hook"
}
}'
exit 0
fi
# 通常の git push --force もブロック(main/master 限定)
if echo "$COMMAND" | grep -qE 'git\s+push\s+(--force|-f).*\b(main|master)\b'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Force push to main detected, please confirm"
}
}'
exit 0
fi
exit 0
ポイント:
- stdin で JSON が渡る(
tool_inputに tool 引数) - stdout で JSON を返すと Claude の挙動を制御
permissionDecision: "deny"で完全 block、"ask"で人間確認を要求
これで「Claude が dangerously-skip-permissions で動いている時でも、絶対に rm -rf / は実行されない」状態を作れる。
実装例 2:自動 format / lint
PostToolUse で Edit/Write の後に自動 format。
.claude/hooks/format.sh:
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE" ]]; then
exit 0
fi
# 拡張子で format 振り分け
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
npx prettier --write "$FILE" 2>/dev/null
;;
*.py)
ruff format "$FILE" 2>/dev/null
ruff check --fix "$FILE" 2>/dev/null
;;
*.go)
gofmt -w "$FILE"
;;
*.rs)
rustfmt "$FILE" 2>/dev/null
;;
esac
exit 0
これで Claude が編集したファイルが自動的に整形される。人間が pre-commit hook でやっていたことを、Claude の編集後にも適用する発想。
実装例 3:CLAUDE.md の再注入
UserPromptSubmit でユーザー入力の前にプロジェクト固有のルールを inject。
.claude/hooks/inject-rules.sh:
#!/bin/bash
INPUT=$(cat)
USER_PROMPT=$(echo "$INPUT" | jq -r '.user_prompt // empty')
# 重要なルールだけを再注入(CLAUDE.md 全体ではない)
RULES=$(cat <<'EOF'
[Reminder] このプロジェクトの絶対ルール:
- DB マイグレーションは必ず確認を取る
- main branch への直接 commit は禁止
- 新規依存追加は事前承認
EOF
)
# 既存プロンプトの前に rule を inject
NEW_PROMPT="${RULES}
${USER_PROMPT}"
jq -n --arg p "$NEW_PROMPT" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: $p
}
}'
これで毎回ユーザー入力の前にルールが注入される。長い CLAUDE.md ではなく、絶対に守らせたい数項目だけを毎ターン強制する設計。
実装例 4:Stop hook で会話の検閲
kazuph の記事 の本丸。Session 終了時に会話履歴を取得し、別 LLM(あるいは安価な Claude Haiku)でルール違反をチェックする。
.claude/hooks/audit.sh:
#!/bin/bash
INPUT=$(cat)
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty')
if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
exit 0
fi
# 直近のアクションだけ抽出
RECENT=$(tail -100 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "tool_use") | .name + ": " + (.input | tostring)' 2>/dev/null | head -20)
# 別の安価モデルでチェック
RESULT=$(echo "$RECENT" | claude -p --model claude-haiku-4 \
"次の行動履歴を見て、main branch への直接 commit や DB マイグレーションの無確認実行など、ルール違反があれば 'VIOLATION: <内容>' の形式で報告。なければ 'OK'。")
if echo "$RESULT" | grep -q "VIOLATION"; then
# Slack / log に通知
echo "[$(date)] $RESULT" >> "$CLAUDE_PROJECT_DIR/.claude/audit.log"
# 必要なら notification skill / MCP 経由で人間に通知
fi
exit 0
「Claude が Claude を監視する」構図。安価モデル(Haiku)を使えばコストも抑えられる。これは L5 への伏線でもある(multi-agent の入り口)。
Hook 設計の落とし穴
落とし穴 1:実行時間が長すぎる
Hook は session に組み込まれるため、遅いと体感品質が落ちる。目安は 1 秒以内。format みたいに必要なら背景化(& で nohup)する。
# 背景化
( npx prettier --write "$FILE" 2>/dev/null & )
exit 0
落とし穴 2:exit code の意味を間違える
exit 0:成功、stdout の JSON で挙動制御exit 2:blocking error(Claude に「失敗」を伝える)- それ以外:non-blocking error(stderr に出るが続行)
exit 2 を使うと Claude が再試行や修正に動く。exit 1 だと「何か起きたが続けろ」になる。意図的に止めたい時は exit 2。
落とし穴 3:JSON エスケープの罠
stdin で受け取る JSON、stdout で返す JSON、いずれも厳密。jq を使わずに sed / awk で組み立てると壊れやすい。素直に jq -n --arg ... でビルドする。
落とし穴 4:debug が困難
Hook の中で何が起きているか、Claude 側からは見えない。ログを取る習慣が必須:
# 各 hook の冒頭
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks.log"
echo "[$(date)] $(basename $0) called with $(echo "$INPUT" | jq -r '.tool_name // .hook_event_name')" >> "$LOG"
これで「あれ、hook 動いてる?」を tail -f .claude/hooks.log で確認できる。
Hook の組み合わせパターン
実用上効くのは複数 hook の組み合わせ:
graph TB
A[UserPromptSubmit<br/>ルール再注入] --> B[PreToolUse<br/>危険コマンド block]
B --> C[PostToolUse<br/>auto format + lint]
C --> D[Stop<br/>違反 audit]
D -.notify.-> S[Slack / log]
style A fill:#fff4e1
style B fill:#ffe1e1
style C fill:#e1ffe1
style D fill:#e1f5ff
これで 入口(UserPromptSubmit)から出口(Stop)まで 全段に監視が入る。Claude は「自由に動いているように見えて、レールから外れたら止まる」状態になる。
L2 でよく使う書き方の例
調査で集めた事例から、汎用性が高いパターンを 4 つ:
A. 特定ディレクトリへの書き込み禁止
# PreToolUse / matcher: Edit|Write
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" == *"/secrets/"* || "$FILE" == *".env"* ]]; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Cannot edit secrets directory"
}
}'
fi
B. テストが落ちたら commit を止める
# PreToolUse / matcher: Bash
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if [[ "$COMMAND" == *"git commit"* ]]; then
if ! npm test --silent 2>/dev/null; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Tests are failing, fix them first"
}
}'
fi
fi
C. 編集行数が多すぎる時に警告
# PostToolUse / matcher: Write
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path')
LINES=$(wc -l < "$FILE")
if [[ "$LINES" -gt 500 ]]; then
echo "[warn] $FILE is now $LINES lines, consider splitting" >> "$CLAUDE_PROJECT_DIR/.claude/warnings.log"
fi
D. Activity tracker
zenn / exwzd の活動可視化記事 のパターン。Hook で「いつ、何の tool を、何回」使ったかを記録し、後から分析する。
# PreToolUse / matcher: ".*"
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
echo "$(date -u +%Y-%m-%dT%H:%M:%S),${TOOL}" >> "$CLAUDE_PROJECT_DIR/.claude/activity.csv"
これで自分の Claude 利用パターンを可視化できる。「何に時間を使っているか」が見えると、L1 の型化候補も自然に浮かぶ。
L2 の典型的な ROI
L2 は L1 と違い、すぐに「時間が浮いた」体感が出にくい。代わりに:
- 失敗回数が減る(危険コマンド、ルール忘れ、format 漏れ)
- 手戻りが減る(CI で落ちなくなる)
- 安心感が増す(dangerously-skip-permissions を打てるようになる)
特に最後が重要。Hook で危険行動を block できるなら、permission prompt なしで Claude を走らせる勇気が出る。これが L3(GitHub Actions)への伏線。
この章の要点
- L2 = Hooks。CLAUDE.md の「ソフト指示」を「ハード強制」に変換
- 5 種類のイベント:SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop
- 設計は イベント × matcher × command の 3 軸
- 実装例:危険コマンド block / 自動 format / ルール再注入 / Stop audit
- 落とし穴:実行時間 / exit code / JSON エスケープ / debug
- 入口〜出口の組み合わせで「自由なのに外れない」状態を作る
- L2 が固まると、permission を緩めて L3 に進める勇気が出る
次章への問いかけ
監視が固まれば、人間が常に画面を見る必要はなくなる。
だが session を起動するのは依然として人間だ。次章では GitHub Actions で起動を event-driven 化する。PR が作られる、issue が立つ、push が起きる ── これらが起動トリガーになる世界に進む。