Prettier を実行するプリ編集フックをセットアップした。自分のマシンでは完璧に動く。同僚のノートPCに持ち込んで Claude Code を動かすと、ファイル編集がすべて謎の終了コードエラーで失敗する。Claude は止まる。何も進まない。同僚からチケットが飛んでくる。
これが多くのチームをつまずかせるフックの失敗パターンだ。フックは同期的なゲートとして機能する。ゼロ以外の終了コードは単なる警告フラグではなく、Claude が行おうとしていた操作をブロックする。PreToolUse フックの場合、Claude のツール呼び出しは実行されない。フック種別によっては、セッション全体が応答を失い、手動での介入が必要になる。
なぜこうなるのかを理解し、その回避策を設計できるかどうか——それがフックを「助けになる存在」にするか「地雷」にするかの分かれ目だ。
フック失敗の実際の動作
Claude Code のフックシステムは終了コードを制御シグナルとして使用する。フック種別ごとの意味は以下のとおり:
| 終了コード | PreToolUse | PostToolUse | Notification | Stop |
|---|---|---|---|---|
0 | ツール呼び出しを許可 | 続行 | 確認済み | 続行 |
1 | ツール呼び出しをブロック | エラーとしてマーク | エラー記録 | 停止確認 |
2 | ブロック + stderr を Claude に送信 | エラーとしてマーク | エラー記録 | 停止確認 |
終了コード 2 は多くの人が知らない仕様だ。PreToolUse フックが 2 で終了すると、Claude はフックの stderr 出力をフィードバックとして受け取り、それに応じて動作を調整できる。終了コード 1 はサイレントにブロックするだけ。終了コード 2 は「説明付きでブロック」するシグナルだ。
実際的な意味: フックがゼロ以外で終了した場合——バイナリが見つからない、ネットワークタイムアウト、フックスクリプト自体の構文エラーなど、いかなる理由でも——Claude の操作はブロックされる。リトライではなく、ブロックだ。
4種類のフックと各ブロックタイミング
PreToolUse
Claude がツールを実行する前に動く: ファイル編集、Bash コマンド、MCP 呼び出し、すべてが対象。
このフックがゼロ以外で終了すると、ツール呼び出しはキャンセルされる。Claude はファイルを書き込まず、コマンドも実行しない。Claude がマルチステップタスクの途中だった場合、通常タスクは止まる——Claude は何が起きたかを把握しようとして再試行するかもしれないが、それはモデルの判断に依存する。
このフックを使うべき用途: セキュリティゲート(機密パスへの書き込みをブロック)、バリデーション(編集前のファイル構文チェック)、レート制限。
このフックを使ってはいけない用途: 環境差異で正当に失敗する可能性があるもの。リンターがインストールされていないために失敗するプリ編集リンターは、リンターが入っていないすべてのマシンで編集をブロックしてしまう。
PostToolUse
Claude がツールを実行した後に動く。この時点では、編集やコマンドはすでに実行済みだ。
このフックがゼロ以外で終了すると、Claude はエラーシグナルを受け取るが、ツール結果はすでに適用されている。失敗した PostToolUse フックは何もロールバックしない。事後的に何かが失敗したことを Claude に伝えるだけで、Claude がそれに対して行動するかどうかは場合による。
このフックを使うべき用途: ロギング、通知、ダウンストリームプロセスのトリガー、編集後のフォーマッター実行。
Notification
Claude が通知イベントを発行するとき(通常はセッション終了やマイルストーンイベント)に動く。
ここでの終了コードはワークフロー制御にとってほぼ無関係——Claude の動作は通知フックにゲートされていない。ブロックを気にせずアラートに自由に使える。
Stop
Claude がセッションを終了しようとするときに動く。
終了コード 0 で停止を許可。ゼロ以外で Claude に「停止せず続行」を示す。これが有用なケースはまれにある(Claude が停止前に特定のアクションを完了させる強制)が、Claude が永遠に終了できない無限ループを誤って作ってしまいやすい。
最もよくあるフック失敗パターン
パターン 1: バイナリが見つからない
フックが prettier、eslint、black、rustfmt、その他のフォーマッターを呼び出す。自分のマシンでは動く。そのツールがインストールされていない場所ではどこでも失敗する。
#!/bin/bash
# prettier がインストールされていないと失敗する
prettier --write "$CLAUDE_TOOL_INPUT_FILE_PATH"
prettier が見つからない場合の終了コード: 127(コマンドが見つからない)。結果: Prettier が入っていないマシンですべてのファイル編集がブロックされる。
修正: 先にバイナリを確認する
#!/bin/bash
FILE_PATH="$CLAUDE_TOOL_INPUT_FILE_PATH"
# prettier が使えるか確認
if ! command -v prettier &> /dev/null; then
# バイナリなし — ログに記録して exit 0 で操作を許可
echo "[hook] prettier not found, skipping format" >&2
exit 0
fi
# バイナリあり — 実行
if ! prettier --write "$FILE_PATH" 2>&1; then
echo "[hook] prettier failed on $FILE_PATH" >&2
# それでも exit 0 — フォーマッター問題で Claude をブロックしない
exit 0
fi
exit 0
重要なパターン: フックのセットアップエラー(ツール不在、環境の違い)では 0 で終了してエラーを stderr にログ記録する。ゼロ以外の終了は意図的なブロックのために残しておく——セキュリティ違反、意図的なゲートのみ。
パターン 2: フックスクリプトの構文エラー
フックが Python スクリプトだとする。更新して構文エラーを入れてしまった。するとフックが即座にクラッシュするため、Claude が試みるすべての操作がブロックされる。
# 構文エラー: コロンが抜けている
def check_file(path)
return True
Python は構文エラーで 1 を返す。結果: すべての PreToolUse フックがサイレントにすべての操作をブロックする。
修正: フック全体をエラーハンドリングで包む
#!/usr/bin/env python3
import sys
import os
import json
def main():
try:
# stdin からフック入力を読み込む
input_data = json.loads(sys.stdin.read())
file_path = input_data.get("tool_input", {}).get("file_path", "")
# 実際のロジックはここ
result = do_check(file_path)
if result.should_block:
# exit 2 でブロックし、Claude に説明を送信
print(result.reason, file=sys.stderr)
sys.exit(2)
sys.exit(0)
except Exception as e:
# 予期しないエラー: ログに記録するが Claude をブロックしない
print(f"[hook error] {e}", file=sys.stderr)
sys.exit(0) # exit 0 — Claude を進めさせる
def do_check(file_path):
# ロジックはここ
pass
if __name__ == "__main__":
main()
最外部の try-except がすべてをキャッチする——インポートエラー、属性エラー、JSON パース失敗——そしてグレースフルな exit 0 に変換する。フックが失敗しても Claude の作業を止めるべきではない。
パターン 3: タイムアウト
フックがネットワーク呼び出しをする——セキュリティ API へのping、レジストリの確認、Slack への投稿。ネットワークが遅い。フックが30秒ハングして、最終的に Claude Code の操作がタイムアウトする。
Claude Code のデフォルトフックタイムアウトは60秒だ。それより長くかかるフックはエラーで強制終了される。しかし実際には、ファイル編集ごとに60秒の操作ブロックはすでに許容できない。
修正: フック内で明示的なタイムアウトを設定する
#!/bin/bash
# ネットワーク呼び出しを制限するために timeout コマンドを使用
RESPONSE=$(timeout 5 curl -s "https://security-api.internal/check?path=$CLAUDE_TOOL_INPUT_FILE_PATH")
CURL_EXIT=$?
if [ $CURL_EXIT -eq 124 ]; then
# timeout コマンドが 124 を返した = タイムアウト
echo "[hook] security API timeout, allowing operation" >&2
exit 0
fi
if [ $CURL_EXIT -ne 0 ]; then
echo "[hook] security API unreachable, allowing operation" >&2
exit 0
fi
# レスポンスの処理
# ...
exit 0
import requests
try:
response = requests.get(
"https://security-api.internal/check",
params={"path": file_path},
timeout=5 # 5秒タイムアウト
)
except requests.exceptions.Timeout:
print("[hook] API timeout, skipping check", file=sys.stderr)
sys.exit(0)
except requests.exceptions.ConnectionError:
print("[hook] API unreachable, skipping check", file=sys.stderr)
sys.exit(0)
フック内のネットワーク呼び出しには常に積極的なタイムアウトを設定すべきだ。API がダウンしていても、Claude は仕事を続けられるようにすること。
パターン 4: 間違った入力ソースを読み込む
Claude Code はツール情報を stdin 経由で JSON としてフックに渡す。環境変数、引数、ファイルから読もうとするフックは必要なデータを取得できずに失敗する。
# 誤り: ツールデータは引数として渡されない
FILE_PATH=$1 # フックには引数が渡されない
# 誤り: この環境変数は存在しない
FILE_PATH=$TOOL_FILE_PATH
修正: 常に stdin を読む
#!/bin/bash
# stdin から JSON 入力を読み込む
INPUT=$(cat)
# jq を使ってフィールドを抽出
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
if [ -z "$FILE_PATH" ]; then
# 編集操作ではない、またはフィールドが存在しない
exit 0
fi
# ロジックを続ける
import json
import sys
input_data = json.loads(sys.stdin.read())
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path")
ファイル編集フックの stdin JSON の完全な構造はこうなる:
{
"session_id": "abc123",
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/file.ts",
"old_string": "const x = 1",
"new_string": "const x = 2"
}
}
フックのデバッグ: 何が失敗しているかを見つける
フックがサイレントに失敗しているとき、実際に何をしているか確認する必要がある。Claude Code はフックの出力を目立つ形では表示しない——自分で探しに行く必要がある。
方法 1: フックを直接実行する
フックはただのスクリプトだ。合成入力で手動実行できる:
# テスト入力を作成
cat > /tmp/hook-test.json << 'EOF'
{
"session_id": "test",
"tool_name": "Edit",
"tool_input": {
"file_path": "/tmp/test.ts",
"old_string": "const x = 1",
"new_string": "const x = 2"
}
}
EOF
# フックを実行して何が起きるか確認
cat /tmp/hook-test.json | bash ~/.claude/hooks/pre-edit.sh
echo "Exit code: $?"
これが最も速く失敗を再現する方法だ。フックを直接実行して終了コードを確認し、stdout/stderr を見る。
方法 2: フックにロギングを追加する
後で確認できるファイルに明示的なログを追加する:
#!/bin/bash
LOG_FILE="/tmp/claude-hooks.log"
echo "[$(date '+%H:%M:%S')] pre-edit hook called" >> "$LOG_FILE"
echo "Input: $(cat)" | tee /tmp/hook-last-input.json >> "$LOG_FILE"
# フックロジックはここ
RESULT=$(do_check)
EXIT_CODE=$?
echo "[$(date '+%H:%M:%S')] exit code: $EXIT_CODE" >> "$LOG_FILE"
exit $EXIT_CODE
Claude を動かしながら別のターミナルでログを tail する:
tail -f /tmp/claude-hooks.log
方法 3: Claude のセッショントランスクリプトを確認する
Claude Code はツール呼び出し結果を含むセッショントランスクリプトを保存する。フック失敗後にトランスクリプトを見て Claude が何を受け取ったかを確認する:
# 最近のセッショントランスクリプトを探す
ls -lt ~/.claude/projects/*/transcripts/ | head -10
# 最新のものを読む
cat ~/.claude/projects/$(ls -t ~/.claude/projects/ | head -1)/transcripts/$(ls -t ~/.claude/projects/$(ls -t ~/.claude/projects/ | head -1)/transcripts/ | head -1)
トランスクリプトは Claude の視点からの出来事を示す——フックが何を返したか、Claude がどう解釈したか。
方法 4: 最小限のフックで切り分ける
複雑なフックのどの部分が失敗しているかわからない場合、一時的に最小限のバージョンに置き換える:
#!/bin/bash
# 最小限のデバッグフック — すべてをログ記録し、常に exit 0
echo "=== DEBUG HOOK ===" >&2
echo "Input received:" >&2
cat | tee /tmp/hook-debug-input.json >&2
echo "=== END INPUT ===" >&2
exit 0
これで分かること: フックがそもそも呼び出されているか? 入力は期待通りの構造か? それが分かれば、実際のロジックを段階的に追加していく。
実用的なフック実装
グレースフルなエラーハンドリングを示す、本番対応の3つのフックを紹介する:
フォーマッターフック(PostToolUse)
Claude がファイルを編集した後にフォーマットする。フォーマット失敗では絶対にブロックしない。
#!/bin/bash
# Post-edit formatter hook
# Claude がファイルを編集した後に動く。絶対にブロックしない。
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# ファイル編集でなければスキップ
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
# 拡張子でフォーマッターを決定
EXT="${FILE_PATH##*.}"
case "$EXT" in
ts|tsx|js|jsx|json|css|html)
if command -v prettier &> /dev/null; then
prettier --write "$FILE_PATH" 2>/dev/null || true
fi
;;
py)
if command -v black &> /dev/null; then
black --quiet "$FILE_PATH" 2>/dev/null || true
fi
;;
go)
if command -v gofmt &> /dev/null; then
gofmt -w "$FILE_PATH" 2>/dev/null || true
fi
;;
rs)
if command -v rustfmt &> /dev/null; then
rustfmt "$FILE_PATH" 2>/dev/null || true
fi
;;
esac
# 常に exit 0 — フォーマット失敗はブロックの理由にならない
exit 0
セキュリティゲートフック(PreToolUse)
機密パスへの書き込みをブロックする。終了コード 2 を使って Claude にブロック理由を説明する。
#!/bin/bash
# Pre-edit security gate
# 機密パスへの書き込みをブロックする。なぜブロックするかを stderr で Claude に説明。
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 編集/書き込み操作のみチェック
case "$TOOL_NAME" in
Edit|Write|MultiEdit) ;;
*) exit 0 ;;
esac
[ -z "$FILE_PATH" ] && exit 0
# 機密パスパターンを定義
SENSITIVE_PATTERNS=(
"^/etc/"
"^/usr/"
"\.env$"
"\.env\."
"credentials"
"secrets\."
"\.pem$"
"\.key$"
"id_rsa"
"id_ed25519"
)
for pattern in "${SENSITIVE_PATTERNS[@]}"; do
if echo "$FILE_PATH" | grep -qE "$pattern"; then
echo "BLOCKED: Write to sensitive path '$FILE_PATH' matches pattern '$pattern'. Use a .env.example or config template instead." >&2
exit 2
fi
done
exit 0
監査ログフック(PostToolUse)
コンプライアンスやデバッグのためにすべてのツール呼び出しを記録する。絶対に失敗しない。
#!/usr/bin/env python3
# Post-tool-use audit logger
# Claude のすべての操作をログファイルに記録する。何もブロックしない。
import json
import sys
import os
from datetime import datetime
LOG_FILE = os.path.expanduser("~/.claude/audit.log")
def main():
try:
input_data = json.loads(sys.stdin.read())
entry = {
"timestamp": datetime.now().isoformat(),
"session_id": input_data.get("session_id", "unknown"),
"tool_name": input_data.get("tool_name", "unknown"),
"tool_input": input_data.get("tool_input", {}),
}
# ログ内の大きな入力を切り詰める
if isinstance(entry["tool_input"], dict):
for key, value in entry["tool_input"].items():
if isinstance(value, str) and len(value) > 500:
entry["tool_input"][key] = value[:500] + "...[truncated]"
with open(LOG_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
# 絶対に失敗しない — 監査ログは透過的であるべき
pass
sys.exit(0)
if __name__ == "__main__":
main()
フック設定: settings と タイムアウト
フックは .claude/settings.json で設定する。関連する構造はこうだ:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/security-gate.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/formatter.sh"
}
]
}
],
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/audit-log.py"
}
]
}
]
}
}
matcher フィールドに指定できるもの:
- 特定のツール名:
"Edit"、"Bash"、"Write"、"MultiEdit" - パイプ区切りの選択肢:
"Edit|Write|MultiEdit" - ワイルドカード:
"*"(すべてのツールにマッチ)
フックは配列に現れる順番で実行される。PreToolUse で最初のフックがゼロ以外で終了した場合、そのリストの後続フックは実行されない。
2026年半ば時点では、JSON 設定にフックごとのタイムアウト設定は組み込まれていない——グローバルタイムアウトがすべてのフックに適用される。フレームワークに任せるのではなく、フックスクリプト内でタイムアウトを設定すること(上記のネットワーク呼び出しの例を参照)。
設計原則: フックは強化するもの、ゲートするものではない
フックを信頼できないものにしてしまう失敗パターンは、ソフトなアドバイザーとして機能すべきものをハードなゲートとして扱うことだ。上記のセキュリティゲートパターンは意図的な例外——それはブロックするべきものだ。フォーマッター、リンター、ロガー、通知機能はブロックするべきではない。それぞれの仕事をして、さっさと退場するべきだ。
設計原則: フックが仕事を完了できない場合、フェイルオープン(失敗時に開放)すべきであり、フェイルクローズド(失敗時に閉鎖)ではない。 フェイルオープンとは exit 0 して問題をログ記録することだ。フェイルクローズドとはゼロ以外で終了して Claude の操作を止めることだ。
ゼロ以外の終了は、本当に止めたいことのために取っておく。「Prettier がインストールされていないから Claude がファイルを編集できなかった」を受け入れられないなら、フォーマッターフックは Prettier が見つからないときにゼロ以外で終了すべきではない。
関連記事
フックシステムには多くの人が探求していない深さがある:
- Claude Code Hooks: 2026年完全プロダクションリファレンス(32以上のイベント、5つのハンドラタイプ、終了コードセマンティクス) — すべてのフック種別の完全なイベントリストと JSON スキーマ。複数のツール種別を異なる方法で処理するフックを構築する際に役立つ
- Claude Code Hooks: 2026年の実世界の自動化パターン12選 — Slack アラート、セキュリティスキャン、テストトリガーの本番パターン。このガイドで説明したグレースフルな失敗パターンで構築されている
堅牢なエラーハンドリングを整えれば、フックは Claude Code ワークフローの中で最も強力な部分の一つになる——チームの標準を強制し、監査証跡を維持し、Claude に手動でやることを覚えさせなくても既存のツールチェーンと統合できる。