Claude Code hooks error handling DevOps automation debugging 2026

Claude Code Hooks: プリ編集フックがすべてを止める理由と対処法

The Prompt Shelf ·

Prettier を実行するプリ編集フックをセットアップした。自分のマシンでは完璧に動く。同僚のノートPCに持ち込んで Claude Code を動かすと、ファイル編集がすべて謎の終了コードエラーで失敗する。Claude は止まる。何も進まない。同僚からチケットが飛んでくる。

これが多くのチームをつまずかせるフックの失敗パターンだ。フックは同期的なゲートとして機能する。ゼロ以外の終了コードは単なる警告フラグではなく、Claude が行おうとしていた操作をブロックする。PreToolUse フックの場合、Claude のツール呼び出しは実行されない。フック種別によっては、セッション全体が応答を失い、手動での介入が必要になる。

なぜこうなるのかを理解し、その回避策を設計できるかどうか——それがフックを「助けになる存在」にするか「地雷」にするかの分かれ目だ。


フック失敗の実際の動作

Claude Code のフックシステムは終了コードを制御シグナルとして使用する。フック種別ごとの意味は以下のとおり:

終了コードPreToolUsePostToolUseNotificationStop
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: バイナリが見つからない

フックが prettiereslintblackrustfmt、その他のフォーマッターを呼び出す。自分のマシンでは動く。そのツールがインストールされていない場所ではどこでも失敗する。

#!/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 ワークフローの中で最も強力な部分の一つになる——チームの標準を強制し、監査証跡を維持し、Claude に手動でやることを覚えさせなくても既存のツールチェーンと統合できる。

Related Articles

Claude Code × GitHub Actions:パーミッションエラーを解決する3つの実証済みパターン(2026年版)

GitHub ActionsでClaude Codeを動かしてパーミッションエラーに悩んでいる人向け。エラーが起きる理由と解決策を、allowlist設定・bypassPermissionsモード・公式claude-code-actionという3つの実証済みパターンで解説する。

Claude Code ヘッドレスモードで PR レビューを自動化する — レビュアーが Claude をインストールできなくても大丈夫

claude -p を使って GitHub Actions で Claude Code の PR レビューを実行する方法を解説。ローカルに Claude を入れられないチームでも、diff の渡し方・コメント自動投稿・セキュリティ/パフォーマンス向けプロンプト・max-turns によるコスト管理まで網羅。

claude -p のJSON出力が本番環境で壊れる — 構造化出力で解決する

パイプラインで claude -p を使って不正なJSONが返ってくる?なぜそれが起きるのか、--output-format json が実際に何をするのか、バリデーションとリトライを含むPython実装の方法を正確に解説する。

Claude Code Plugins 完全リファレンス 2026 — 7コンポーネント型・plugin.json・Marketplace 完全解説

Claude Code plugin の全コンポーネントを1ページで網羅。Anthropic 公式 docs 2026/5 検証済み。Skills/Agents/Hooks/MCP servers/LSP servers/Monitors/Commands、plugin.json スキーマ、.claude-plugin/ 構造、Marketplace 配布、本番パターン全部入り。

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules