メインコンテンツへスキップ
ブログ一覧

Claude Code Hooks実践ガイド — 全24イベント解説・セキュリティゲート・defer承認ワークフロー・MCP連携の本番パターン集

(更新: 2026年04月11日)
Claude CodeHooksセキュリティCI/CD自動化MCP連携

Hooks とは何か — CLAUDE.md・permissions との責務分担

Hooks の位置づけ:「いつ・何を・どう制御するか」のレイヤー

Claude Code の制御機構は3層に分かれる。CLAUDE.md は「何を知っているか」を伝える静的知識層。Permission Mode は allow/deny の二値で操作全体を制御する粒度層。そして Hooks は「特定の条件を満たしたとき、特定のロジックを実行する」イベント駆動の自動化層だ。

Git Hooks との類似は意図的な設計だろう。ライフサイクルイベントにスクリプトを紐づける構造は同じだが、Claude Code Hooks には permissionDecision による制御フロー変更(allow / deny / ask / defer の4値)と、4種のハンドラタイプ(後述)という独自の拡張がある。

CLAUDE.md = 知識、permissions = 粒度、Hooks = 自動化ロジック

設定ファイルの配置場所と優先順位を押さえておこう。

優先順位 配置場所 スコープ
1(最高) Managed policy settings Enterprise組織全体
2 .claude/settings.local.json プロジェクト(gitignore対象)
3 .claude/settings.json プロジェクト(コミット可)
4(最低) ~/.claude/settings.json グローバル(全プロジェクト)

複数階層に同一イベントの Hook が定義されている場合、すべてが実行される。deny が1つでもあれば deny が優先される(後述の決定優先順位を参照)。

最小限の設定例として、Bash ツールで rm を含むコマンドをブロックする Hook を示す。

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command // empty' | grep -qE '\\brm\\b' && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"rm command is blocked by security policy\"}}' || true"
          }
        ]
      }
    ]
  }
}

全26イベント × 4ハンドラタイプ 使い分けマトリクス

ライフサイクル全体図

Hook イベントはセッションのライフサイクルに沿って発火する。大きく8フェーズに分類できる。

セッション系(6イベント)

イベント 発火タイミング matcher
SessionStart セッション開始・再開 startup, resume, clear, compact
SessionEnd セッション終了 clear, resume, logout, prompt_input_exit
InstructionsLoaded CLAUDE.md / rules ファイル読み込み時 session_start, nested_traversal
ConfigChange 設定ファイル変更時 user_settings, project_settings
CwdChanged 作業ディレクトリ変更時 なし
FileChanged 監視ファイル変更時 リテラルファイル名

プロンプト系(2イベント)

イベント 発火タイミング ブロック可否
UserPromptSubmit ユーザーがプロンプト送信後、処理前 exit 2 でブロック
Stop Claude が応答完了時 exit 2 でブロック

ツール実行系(6イベント)

イベント 発火タイミング ブロック可否
PreToolUse ツール実行前 permissionDecision で制御
PermissionRequest 権限ダイアログ表示時 decision.behavior で制御
PermissionDenied Auto Mode がツール拒否時 リトライ可能
PostToolUse ツール成功後 exit 2
PostToolUseFailure ツール失敗後 不可
StopFailure APIエラーでターン終了時 不可

サブエージェント・チーム系(3イベント)

イベント 発火タイミング matcher
SubagentStart サブエージェント生成時 エージェントタイプ
SubagentStop サブエージェント完了時 エージェントタイプ
TeammateIdle チームメイトがアイドル状態に入った時 なし

タスク系(2イベント): TaskCreated / TaskCompleted Worktree系(2イベント): WorktreeCreate / WorktreeRemove コンパクト系(2イベント): PreCompact / PostCompact(matcher: manual / auto通知系(1イベント): Notification(Claude Codeが通知を送信する際に発火) MCP Elicitation系(2イベント): Elicitation / ElicitationResult(matcher: MCPサーバー名)

4ハンドラタイプの選定基準

タイプ 実行方式 追加コスト ユースケース
Command シェルコマンド なし 正規表現マッチ、ファイル操作、ログ記録
HTTP URL に POST なし(外部サーバー必要) Slack 通知、外部監査ログ、Webhook連携
Prompt Claude 単発判定 トークン消費あり 自然言語ルール評価、曖昧な条件判定
Agent サブエージェント起動 トークン消費大 コードベース状態を参照する重量級検証

判断フローはシンプルだ。正規表現で判定できるなら Command。外部システムに通知するなら HTTP。自然言語で判定したいなら Prompt。コードを読んで判定が必要なら Agent。Prompt と Agent はトークンコストが発生するため、PostToolUse のような高頻度イベントに設定するとコストが跳ねる点に注意が必要だ。

イベント発火順を確認するデバッグ用 Hook を貼っておく。

json
{
  "hooks": {
    "PreToolUse": [{ "hooks": [{ "type": "command", "command": "echo \"$(date '+%H:%M:%S') PreToolUse $(jq -r '.tool_name' 2>/dev/null)\" >> /tmp/hooks.log" }] }],
    "PostToolUse": [{ "hooks": [{ "type": "command", "command": "echo \"$(date '+%H:%M:%S') PostToolUse $(jq -r '.tool_name' 2>/dev/null)\" >> /tmp/hooks.log" }] }],
    "Stop": [{ "hooks": [{ "type": "command", "command": "echo \"$(date '+%H:%M:%S') Stop\" >> /tmp/hooks.log" }] }],
    "SessionStart": [{ "hooks": [{ "type": "command", "command": "echo \"$(date '+%H:%M:%S') SessionStart\" >> /tmp/hooks.log" }] }],
    "SessionEnd": [{ "hooks": [{ "type": "command", "command": "echo \"$(date '+%H:%M:%S') SessionEnd\" >> /tmp/hooks.log" }] }]
  }
}

セキュリティゲート設計 — PreToolUse で守る本番パターン

permissionDecision の4値

PreToolUse(と PermissionRequest)でのみ返却可能な permissionDecision は、厳密な優先順位を持つ。

deny > defer > ask > allow

複数の Hook が異なる決定を返した場合、最も制限の厳しい決定が採用される。重要な性質として、deny は --dangerously-skip-permissions モードでもブロックが有効だ。Hooks はユーザーがバイパスできないセキュリティ境界として機能する。

パターン1:危険コマンドのブロック

bash
#!/bin/sh
# hooks/block-dangerous.sh
# stdin から JSON を読み取り、危険なコマンドをブロック

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')

if [ "$TOOL_NAME" = "Bash" ]; then
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

  # rm -rf, git push --force, DROP TABLE をブロック
  if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f|git\s+push\s+.*--force|DROP\s+TABLE'; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Destructive command blocked. Use a safer alternative."}}'
    exit 0
  fi
fi

パターン2:ファイルパス保護

bash
#!/bin/sh
# hooks/protect-files.sh
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')

# Edit / Write ツールで .env や credentials を保護
if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

  if echo "$FILE_PATH" | grep -qE '\.(env|pem|key)$|credentials|secrets'; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Sensitive file is protected. Request manual edit instead."}}'
    exit 0
  fi
fi

settings.json にまとめて登録する。

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "sh hooks/block-dangerous.sh" }]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "sh hooks/protect-files.sh" }]
      }
    ]
  }
}

deny 時に permissionDecisionReason で代替行動を示唆すると、Claude が自律的にリカバリ手段を探してくれる。「ブロックしました」だけだと Claude が同じ操作を繰り返すことがあるので、理由は具体的に書くのがコツだ。

パターン3:Prompt ハンドラによる自然言語審査

正規表現では判定しきれない場合、Prompt ハンドラで Claude に判定させる。

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "以下のコマンドが本番環境に影響を与える可能性があるか判定してください。環境変数の変更、外部APIへのリクエスト、データベースへの書き込みを含む場合はブロックしてください。コマンド: $ARGUMENTS",
            "statusMessage": "セキュリティ審査中..."
          }
        ]
      }
    ]
  }
}

ただし、Prompt ハンドラは毎回トークンを消費する。Bash ツールは高頻度で呼ばれるため、本当に必要なケースに絞ること。正直、最初にこれを全ツールに設定して請求額を見たときは冷や汗をかいた。

よくあるハマりポイント

  • tool_name の正確な値: Bash, Read, Edit, Write, Glob, Grep, Agent, NotebookEdit など。大文字始まり
  • Hook のシェルは /bin/sh: [[ ]] や配列は使えない。bash 固有構文は bash -c '...' で明示的に囲む
  • permissionDecision を返せるのは PreToolUsePermissionRequest のみ: 他イベントで返しても無視される

defer 承認ワークフロー — CI/CD に承認ゲートを組み込む

defer の仕組み

defer-p(ヘッドレスモード)専用の permissionDecision 値だ(v2.1.89+)。ツール実行を一時停止し、外部プロセスからの承認を待つ。

フローは以下の通り。

  1. ツール発火 → PreToolUse Hook が "defer" を返す
  2. セッションが stop_reason: "tool_deferred" で一時停止。deferred_tool_use ペイロードが返る
  3. 外部プロセス(Slack Bot、CI/CD パイプライン等)が承認判断を収集
  4. claude -p --resume <session-id> でセッション再開
  5. 同一ツールの PreToolUse が再発火 → Hook が "allow" を返す → ツール実行

制約: ターン内の単一ツール呼び出し時のみ有効。複数ツールのバッチ呼び出し時は defer が無視される。

実装例:本番デプロイ前の Slack 承認ゲート

defer を返す Hook。

bash
#!/bin/sh
# hooks/defer-deploy.sh
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')

if [ "$TOOL_NAME" = "Bash" ]; then
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

  if echo "$COMMAND" | grep -qE 'vercel deploy --prod|npm run deploy'; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer","permissionDecisionReason":"Production deploy requires approval"}}'
    exit 0
  fi
fi

承認後にセッションを再開する Node.js スクリプト。

javascript
// approve-and-resume.mjs
import { execSync } from "child_process";

const sessionId = process.argv[2];
const decision = process.argv[3]; // "allow" or "deny"

if (!sessionId || !decision) {
  console.error("Usage: node approve-and-resume.mjs <session-id> <allow|deny>");
  process.exit(1);
}

// セッションを再開(再開時に同じ PreToolUse が発火する)
// 承認済みフラグをファイルで管理する簡易実装
if (decision === "allow") {
  // 承認フラグを書き込み、Hook 側で参照させる
  const fs = await import("fs");
  fs.writeFileSync(`/tmp/approved-${sessionId}`, "approved");
}

const result = execSync(
  `claude -p --resume "${sessionId}" --no-input`,
  { encoding: "utf-8", timeout: 300000 }
);
console.log(result);

Hook 側を承認フラグ対応に拡張する。

bash
#!/bin/sh
# hooks/defer-deploy.sh(承認フラグ対応版)
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')

if [ "$TOOL_NAME" = "Bash" ]; then
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

  if echo "$COMMAND" | grep -qE 'vercel deploy --prod|npm run deploy'; then
    # 承認フラグがあれば allow
    if [ -f "/tmp/approved-${SESSION_ID}" ]; then
      rm "/tmp/approved-${SESSION_ID}"
      echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
    else
      echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer","permissionDecisionReason":"Production deploy requires Slack approval"}}'
    fi
    exit 0
  fi
fi

実運用では承認フラグの管理を Redis や DB に移し、Slack の Interactive Message と Lambda/Worker を組み合わせて承認ボタン → resume の自動化を構築する形になる。


MCP ツール連携 — mcp<server><tool> パターンで外部ツールを制御

MCP ツールの命名規則

MCP ツールは mcp__<server>__<tool> のダブルアンダースコア区切りで tool_name に入る。<server>mcpServers 設定のキー名がそのまま使われる。

code
mcp__supabase__query
mcp__github__create_pull_request
mcp__memory__create_entities

matcher フィールドは正規表現なので、サーバー単位のフィルタリングが可能だ。

実践:破壊的 MCP 操作を deny する

bash
#!/bin/sh
# hooks/mcp-guard.sh
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')

# Supabase MCP の破壊的 SQL をブロック
if echo "$TOOL_NAME" | grep -q '^mcp__supabase__'; then
  QUERY=$(echo "$INPUT" | jq -r '.tool_input.query // .tool_input.sql // empty' | tr '[:lower:]' '[:upper:]')

  if echo "$QUERY" | grep -qE 'INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE'; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Write operations on Supabase require manual execution. Use SELECT only."}}'
    exit 0
  fi
fi

# GitHub MCP の main ブランチへの PR を ask に回す
if [ "$TOOL_NAME" = "mcp__github__create_pull_request" ]; then
  BASE=$(echo "$INPUT" | jq -r '.tool_input.base // empty')
  if [ "$BASE" = "main" ] || [ "$BASE" = "master" ]; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"PR to main branch requires manual approval"}}'
    exit 0
  fi
fi

settings.json への登録は matcher でサーバー名を指定する。

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__supabase__.*|mcp__github__.*",
        "hooks": [{ "type": "command", "command": "sh hooks/mcp-guard.sh" }]
      }
    ]
  }
}

HTTP ハンドラで全 MCP 呼び出しを外部監査ログに送るパターンも有効だ。

json
{
  "matcher": "mcp__.*",
  "hooks": [
    {
      "type": "http",
      "url": "https://audit.example.com/hooks/mcp-calls",
      "headers": { "Authorization": "Bearer $AUDIT_TOKEN" },
      "allowedEnvVars": ["AUDIT_TOKEN"],
      "timeout": 5
    }
  ]
}

Agent Teams・Worktree 環境での Hooks 伝播挙動

SubagentStart/Stop と Hooks の継承

親エージェントの settings.json に定義された Hooks は、サブエージェントにも伝播する。グローバル設定(~/.claude/settings.json)の Hooks はすべてのエージェントに適用され、プロジェクト設定(.claude/settings.json)の Hooks は同一プロジェクト内のサブエージェントに適用される。

SubagentStart で子エージェントの起動を監査する例。

json
{
  "hooks": {
    "SubagentStart": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.slack.com/services/T.../B.../xxx",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

WorktreeCreate/Remove の活用

WorktreeCreate--worktree フラグや isolation: "worktree" で Worktree が作成されたときに発火する。Worktree 環境では .claude/settings.json が Worktree 側にもコピーされるため、プロジェクトレベルの Hook は Worktree 内でも有効だ。

TeammateIdle は Agent Teams でチームメイトがアイドル状態に入るときに発火する。長時間タスクの監視に使える。

json
{
  "hooks": {
    "TeammateIdle": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo \"[$(date)] Teammate idle\" >> /tmp/agent-team.log"
          }
        ]
      }
    ]
  }
}

実運用 Tips:デバッグ・パフォーマンス・チーム展開

Hooks のデバッグ

Hook の入出力をファイルにダンプするラッパースクリプトが便利だ。

bash
#!/bin/sh
# hooks/debug-wrapper.sh
# Usage: sh hooks/debug-wrapper.sh <actual-hook-script>

INPUT=$(cat)
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')

# 入力をダンプ
echo "$INPUT" | jq . > "/tmp/hook_${HOOK_EVENT}_${TIMESTAMP}_input.json"

# 実際の Hook を実行
OUTPUT=$(echo "$INPUT" | sh "$1" 2>/tmp/hook_${HOOK_EVENT}_${TIMESTAMP}_stderr.log)
EXIT_CODE=$?

# 出力をダンプ
echo "$OUTPUT" > "/tmp/hook_${HOOK_EVENT}_${TIMESTAMP}_output.json"

# 結果を返す
echo "$OUTPUT"
exit $EXIT_CODE

パフォーマンスとタイムアウト

Command ハンドラのデフォルトタイムアウトは 600秒、HTTP ハンドラは timeout フィールドで指定する。タイムアウトした Hook は非ブロッキングエラーとして処理され、ツール実行は続行される。

パフォーマンス上の注意点として、PostToolUse に重い Hook を設定すると全ツール実行後に遅延が入る。async: true を設定すればバックグラウンド実行になるが、その場合は結果を待たずに次の処理に進む。

Enterprise policy との組み合わせ

チーム展開では Managed policy で Hooks を強制適用するのが定石だ。Managed policy に定義された Hook は個人の disableAllHooks: true では無効化できない。セキュリティゲートを全メンバーに強制しつつ、個人レベルの便利 Hook は自由に追加させる運用が可能になる。

Hook プロセスに渡される主要な環境変数は以下の通り。

変数 用途
CLAUDE_PROJECT_DIR プロジェクトルートディレクトリ
CLAUDE_ENV_FILE 環境変数永続化ファイルのパス(SessionStart 等)
CLAUDE_CODE_REMOTE Web環境で "true"

また、入力 JSON には session_id, cwd, permission_mode, hook_event_name などが共通フィールドとして含まれる。


まとめ

Hooks は Claude Code の「自由度」と「統制」を両立させる仕組みだ。Auto Mode で生産性を上げつつ、PreToolUse でセキュリティ境界を引き、defer で人間の承認を非同期に挟み、MCP マッチングで外部ツールの暴発を防ぐ。

本記事で紹介したパターンを組み合わせれば、チームで Claude Code を導入する際の「何が起きるかわからない不安」を settings.json 1つで解消できる。まずは最小限のセキュリティゲート(危険コマンドブロック + .env 保護)から始めて、運用に合わせて defer 承認や MCP 制御へ段階的に拡張していくのがおすすめだ。

もっと読む他の技術記事も読む