ankuro.dev
← ブログ一覧に戻る
Claude Code Hooks 完全ガイド——全イベント・条件設定・ブロック設計を組み合わせる
2026-03-31#Claude Code#AI#Hooks#セキュリティ#自動化

Claude Code Hooks 完全ガイド——全イベント・条件設定・ブロック設計を組み合わせる

Hooksは、Claude Codeの動作の特定タイミングにシェルスクリプトやPythonスクリプトを自動で割り込ませる仕組み。「危険なコマンドを実行前に止める」「gitコマンドのたびにログを残す」「ディレクトリを移動したらAWSプロファイルを切り替える」——こういった処理を、Claudeへの指示なしに自動で動かせる。(Hooksの基本は中級編 #2:Hooksとは何かで解説している。)

これが重要なのは、Claude Code自体の動作を制御できるから。通常、Claudeが何かするのを止めるには毎回確認ダイアログに答えるか、プロンプトで縛るしかない。Hooksを使うと、そのガードレールをコードとして設定ファイルに書いておける。一度設定すれば全セッションに適用され、チームでも共有できる。

Hooksの個別記事を読んだけど「結局どう組み合わせればいいか」がわからない——この記事はそのための一枚。

以下がわかる:

  • 7つのHookイベントの使いどころと入力データの形式
  • matcher・ifフィールド・スクリプト内チェックの3層設計
  • decision: "block" が正しい形式と、効かない書き方
  • 全部実測済みのフル構成例(guard・ログ・環境変数切り替え)
  • やりがちな間違い4選

Hooksの全体マップ

Hooksは「Claude Codeの動作の特定タイミングにスクリプトを割り込ませる仕組み」。イベントが7種類ある。

イベント 発火タイミング inputに含まれる主なデータ
PreToolUse ツール実行の直前 tool_nametool_input.command(Bash)・tool_input.file_path(Edit/Write)
PostToolUse ツール実行の直後 tool_nametool_inputtool_response
Stop Claude Codeが正常に応答を終了したとき stop_hook_activelast_assistant_message
StopFailure APIエラーでターンが終了したとき errorlast_assistant_message
CwdChanged カレントディレクトリが変わったとき cwd(新しいディレクトリ)
FileChanged 監視ファイルが変更されたとき file_path・matcher設定が必須
PermissionDenied auto mode分類器がツールを拒否したとき auto mode有効時のみ発火(inputの詳細構造は要確認)

どれを使うか迷ったときの判断基準:

ツールの実行を制御したい       → PreToolUse(実行前にチェック・ブロック)
ツールの実行後に何かしたい     → PostToolUse(ログ・通知・lint)
正常終了後に何かしたい         → Stop(サマリー生成・通知)
APIエラー時に自動で対処したい  → StopFailure(エラーログ・Slack通知)
ディレクトリ移動に反応したい   → CwdChanged(環境変数切り替え)
ファイル変更に反応したい       → FileChanged(設定ファイルの監視)
auto modeの拒否に割り込みたい  → PermissionDenied(retry制御)

settings.jsonの基本構造

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "command": "/path/to/script.py"
          }
        ]
      }
    ],
    "CwdChanged": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/script.py"
          }
        ]
      }
    ]
  }
}

ファイルの種類と優先順位(高い順):

  1. .claude/settings.local.json(プロジェクト・ローカルのみ)
  2. .claude/settings.json(プロジェクト共有)
  3. ~/.claude/settings.json(グローバル)

条件絞り込みの3層設計

Hooksの精度は3段階で上げられる。

層1: matcher ——ツール種別で絞る(Bash / Write / Edit / Read)
     ↓
層2: if ————コマンド内容・ファイルパスで絞る(Bash(git *) / Write(*.ts))
     ↓
層3: スクリプト内チェック ——複雑な条件(正規表現・外部API・複数条件の組み合わせ)

層1: matcher

{"matcher": "Bash"}   // Bashコマンド全体
{"matcher": "Write"}  // ファイル書き込み全体
{"matcher": "Edit"}   // ファイル編集全体

matcherを省略するとすべてのツールに反応する。

層2: if フィールド(v2.1.85以降)

if内側のhookオブジェクト内に書く。外側に書くと無視される(エラーも出ない)。

// ✅ 正しい(ifがhookオブジェクト内)
{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "if": "Bash(git *)",
      "command": "python3 /path/to/script.py"
    }
  ]
}

// ❌ 間違い(ifが外側 → 無視されて全Bashで発火する)
{
  "if": "Bash(git *)",
  "hooks": [{"type": "command", "command": "..."}]
}

permission rule構文の例:

パターン 意味
Bash(git *) gitで始まるコマンド
Bash(rm *) rmで始まるコマンド
Write(*.ts) .tsファイルへの書き込み
Edit(*.env*) .envを含むファイルの編集
Read(**/secret*) secretを含むパスの読み取り

層3: スクリプト内チェック

複数の危険パターンをリストで管理したり、パスの内容で細かく分岐したりする場合はスクリプト内で処理する。


ブロック・制御の正しい書き方

正しい形式(実測済み)

import json, sys

input_data = json.loads(sys.stdin.read())
cmd = input_data.get("tool_input", {}).get("command", "")  # ← tool_input が必須

if "rm -rf" in cmd:
    print(json.dumps({
        "decision": "block",   # ← "allow: False" は効かない
        "reason": "rm -rfをブロック"
    }))
    sys.exit(0)   # ← sys.exit(0)で終了(exit 2でもブロックできる)

よくある間違い(全部実測で確認)

allow: False は効かない

# ❌ これはブロックされない(素通りする)
print(json.dumps({"allow": False, "reason": "..."}))
sys.exit(1)

# ✅ これが正しい
print(json.dumps({"decision": "block", "reason": "..."}))
sys.exit(0)

tool_input を忘れる

# ❌ commandが空文字になる
cmd = input_data.get("command", "")

# ✅ tool_input経由で取得する
cmd = input_data.get("tool_input", {}).get("command", "")

if を外側に書く

→ 上記「層2」の解説を参照。エラーなく無視されるため気づきにくい。

④ CLAUDE_ENV_FILEにunsetを書かない

CwdChangedでAWSプロファイルを切り替えるとき、前の値が残り続ける。

# ❌ 前のプロファイルが残る
with open(env_file, "a") as f:
    f.write(f"export AWS_PROFILE={profile}\n")

# ✅ 先にunsetしてから書く
with open(env_file, "a") as f:
    f.write(f"unset AWS_PROFILE\n")
    f.write(f"export AWS_PROFILE={profile}\n")

実践フル構成例(全部動作確認済み)

PreToolUse・PostToolUse・CwdChangedを同時設定しても干渉しないことを実測で確認済み。

settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm -rf*)",
            "command": "python3 ~/.claude/hooks/guard.py"
          },
          {
            "type": "command",
            "if": "Bash(git push --force*)",
            "command": "python3 ~/.claude/hooks/guard.py"
          },
          {
            "type": "command",
            "if": "Bash(playwright-cli open*)",
            "command": "python3 ~/.claude/hooks/check_playwright_url.py"
          }
        ]
      }
    ],
    "CwdChanged": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/switch_aws_profile.py"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "command": "python3 ~/.claude/hooks/log_git.py"
          }
        ]
      }
    ]
  }
}

guard.py(危険コマンドのブロック)

#!/usr/bin/env python3
import json, sys

input_data = json.loads(sys.stdin.read())
cmd = input_data.get("tool_input", {}).get("command", "")

dangerous_patterns = ["rm -rf", "git push --force", "DROP TABLE", "kubectl delete"]

for pattern in dangerous_patterns:
    if pattern in cmd:
        print(json.dumps({
            "decision": "block",
            "reason": f"危険なコマンドをブロック: {pattern}"
        }))
        sys.exit(0)

switch_aws_profile.py(CwdChanged + CLAUDE_ENV_FILE)

#!/usr/bin/env python3
import json, sys, os

input_data = json.loads(sys.stdin.read())
new_cwd = input_data.get("cwd", "")
env_file = os.environ.get("CLAUDE_ENV_FILE", "")

profile_map = {
    "/Users/yourname/projects/prod": "prod-profile",
    "/Users/yourname/projects/dev":  "dev-profile",
}

for path, profile in profile_map.items():
    if new_cwd.startswith(path) and env_file:
        with open(env_file, "a") as f:
            f.write(f"unset AWS_PROFILE\n")
            f.write(f"export AWS_PROFILE={profile}\n")
        sys.exit(0)

log_git.py(gitコマンドのPostToolUseログ)

#!/usr/bin/env python3
import json, sys, os
from datetime import datetime

input_data = json.loads(sys.stdin.read())
cmd = input_data.get("tool_input", {}).get("command", "")

log_path = os.path.expanduser("~/.claude/git_history.log")
with open(log_path, "a") as f:
    f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S}  {cmd}\n")

check_playwright_url.py(URLのホワイトリスト制限)

#!/usr/bin/env python3
import json, sys

input_data = json.loads(sys.stdin.read())
cmd = input_data.get("tool_input", {}).get("command", "")

if "playwright-cli open" in cmd:
    allowed_urls = ["calculator.aws", "console.aws.amazon.com"]
    if not any(url in cmd for url in allowed_urls):
        print(json.dumps({
            "decision": "block",
            "reason": f"playwright-cli: 許可されていないURLへのアクセス: {cmd}"
        }))
        sys.exit(0)

まとめ

やりたいこと 使うもの
危険コマンドをブロック PreToolUse + if + decision: "block"
gitコマンドだけログを残す PostToolUse + if: "Bash(git *)"
ディレクトリ移動で環境変数を切り替え CwdChanged + CLAUDE_ENV_FILE
ブラウザ操作のURL制限 PreToolUse + if: "Bash(playwright-cli open*)"
TypeScriptファイル保存時にlint PostToolUse + if: "Write(*.ts)"

設計の原則:

  1. if フィールドで発火を絞り、不要なプロセス起動を減らす
  2. ブロックは decision: "block" + sys.exit(0)
  3. inputは必ず tool_input.command 経由で取得する
  4. CLAUDE_ENV_FILEへの書き込みは unset を先に入れる
  5. 複数イベントを同時設定しても干渉しない

関連記事