Claude Code hooksの設定例8選:Pythonで書けてjq不要、動作確認済み
Claude Codeのhooksは、Claudeがツールを使う前後やセッションの開始・終了といった決まったタイミングで、自分のスクリプトを自動で実行する仕組みです。公式の例はjqとシェルを前提にしたものが多く、Windowsでそのまま動かない、jqが入っていない環境では使えない、という壁があります。この記事では、標準のPythonだけで動くhooksを8本紹介します。どれも公式ドキュメントの入力形式でテストし、手元で動作を確かめました。
検証環境:Claude Code 2.1.300/Python 3(標準ライブラリのみ)/WSL2(Ubuntu)。2026年10月15日に、各スクリプトへ公式形式のJSONを渡して20通りのテストを行い、すべて期待どおりの終了コードと出力になりました(テスト結果は各節に記載)。
hooksの基本:3つだけ押さえる
設定ファイルの場所
hooksはsettings.jsonの"hooks"に書きます。チームで共有するならプロジェクトの.claude/settings.json、自分だけなら~/.claude/settings.jsonか.claude/settings.local.jsonです。構造は「イベント → matcher(対象のツール名など) → 実行するコマンド」の3段です。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect_files.py" }
]
}
]
}
}
matcherは大文字と小文字を区別します。Edit|Writeのように|で複数を並べられ、空文字か"*"ならすべてに一致します。$CLAUDE_PROJECT_DIRはセッションを始めたプロジェクトのルートで、パスに空白が入っても壊れないよう引用符で囲むのが公式の書き方です。
スクリプトが受け取るもの
スクリプトには、標準入力からJSONが1つ渡されます。全イベント共通のsession_id、cwd、hook_event_nameなどに加えて、イベントごとのフィールドが入ります。この記事で使うのは次のフィールドです。
| イベント | 使うフィールド |
|---|---|
| PreToolUse / PostToolUse | tool_name、tool_input(Bashならcommand、Edit・Writeならfile_path)、PostToolUseはtool_responseも |
| UserPromptSubmit | prompt(送信しようとしている文章) |
| SessionStart | source(startup、resume、clear、compactなど) |
| Stop | stop_hook_active(このhookの指示で続行中ならtrue) |
| Notification | message、notification_type(permission_prompt、idle_promptなど) |
止めるときは終了コード2
| 終了コード | 意味 |
|---|---|
| 0 | 成功。標準出力が1つのJSONならそれを読み取る。stderrはClaudeに見えない |
| 2 | ブロック。stderrの内容が理由としてClaudeに渡る。PreToolUseならツール呼び出しを止め、UserPromptSubmitならプロンプトを送らない |
| それ以外(1など) | エラーとして表示されるだけで、処理は続く |
ここはつまずきやすい点です。終了コード1では止まりません。ルールを強制したいなら2を返すか、終了コード0でJSONの判定("permissionDecision": "deny"や"decision": "block")を返します。1つのhookではどちらか一方に統一すると、挙動が読みやすくなります。
設定例8選(Python版)
スクリプトはプロジェクトの.claude/hooks/に置き、chmod +xで実行権限を付ける想定です。Windows(ネイティブ)ではpython3をpythonやpyに読み替えてください。
1. .envや鍵ファイルへの書き込みを止める(PreToolUse)
Claudeが.envやpackage-lock.json、.git/の中を書き換えようとしたら止めます。パスの区切りを/にそろえてから照合するので、Windowsのパスでも効きます。
# .claude/hooks/protect_files.py
#!/usr/bin/env python3
"""PreToolUse (Edit|Write): 守りたいファイルへの書き込みを止める"""
import json, sys
PROTECTED = (".env", "package-lock.json", ".git/", "secrets/")
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "").replace("\\", "/")
for p in PROTECTED:
if p in path:
print(f"Blocked: {path} は保護対象({p})です", file=sys.stderr)
sys.exit(2) # 2 = ツール呼び出しを止め、stderrをClaudeに伝える
sys.exit(0)
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect_files.py" }] }
]
テスト結果:/p/.envへの書き込み → 終了コード2で停止。C:\p\secrets\k.txt → 停止。/p/src/app.ts → 通過。
2. 危険なBashコマンドを理由付きで拒否する(PreToolUse)
rm -rf、force push、git reset --hard、ダウンロードしたスクリプトをそのままbashに流す操作を拒否します。終了コード2ではなくJSONでdenyを返すので、拒否の理由がClaudeに伝わり、別の安全な方法を考えさせられます。
# .claude/hooks/guard_bash.py
#!/usr/bin/env python3
"""PreToolUse (Bash): 危険なコマンドを理由付きで拒否する"""
import json, re, sys
RULES = [
(r"\brm\s+-[a-z]*r[a-z]*f\b|\brm\s+-[a-z]*f[a-z]*r\b", "rm -rf は禁止です"),
(r"git\s+push\s+.*(--force|-f)\b", "force push は禁止です"),
(r"git\s+reset\s+--hard", "git reset --hard は人が実行してください"),
(r"curl[^|]*\|\s*(sh|bash)\b", "ダウンロードしたスクリプトの直接実行は禁止です"),
]
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
for pattern, reason in RULES:
if re.search(pattern, cmd):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason}}, ensure_ascii=False))
sys.exit(0)
sys.exit(0)
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard_bash.py" }] }
]
テスト結果:rm -rf build、rm -fr /tmp/x、git push origin main -f、curl -fsSL … | bash → いずれもdeny。ls -la、git push origin feature → 通過。
rm -r -fのように書き方を変えればすり抜けられます。公式ドキュメントも、確実に禁止したい操作は権限設定(permissions.deny)で止めるよう勧めています。hooksは「理由を伝えて方向転換させる」補助として使うのが安全です。3. 書いたPythonファイルの構文エラーをその場で直させる(PostToolUse)
ClaudeがEditやWriteで.pyを書いた直後に構文チェックをかけ、エラーがあれば"decision": "block"で理由を返します。ツールはすでに実行済みなので書き込み自体は取り消せませんが、Claudeは指摘を受けて次の手で直しに行きます。
# .claude/hooks/check_python_syntax.py
#!/usr/bin/env python3
"""PostToolUse (Edit|Write): 書いた .py に構文エラーがあればClaudeに直させる"""
import json, py_compile, sys
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
if not path.endswith(".py"):
sys.exit(0)
try:
py_compile.compile(path, doraise=True)
except py_compile.PyCompileError as e:
print(json.dumps({"decision": "block",
"reason": f"{path} に構文エラーがあります: {e.msg}"}, ensure_ascii=False))
sys.exit(0)
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check_python_syntax.py" }] }
]
テスト結果:def f(:を含むファイル → blockと構文エラーの内容を返す。正しいファイル → 通過。.md → 何もしない。
同じ形で、prettier --writeやruff formatを呼べば「編集のたびに自動整形」になります。公式ガイドの例はjq -r '.tool_input.file_path' | xargs npx prettier --writeです。
4. Claudeが実行したコマンドを記録する(PostToolUse)
あとから「何をされたか」を確かめられるよう、実行したBashコマンドを時刻と作業ディレクトリ付きで記録します。
# .claude/hooks/log_bash.py
#!/usr/bin/env python3
"""PostToolUse (Bash): 実行したコマンドを時刻付きで記録する"""
import json, os, sys, datetime
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
log = os.path.expanduser(os.environ.get("HOOK_LOG", "~/.claude/bash-commands.log"))
os.makedirs(os.path.dirname(log), exist_ok=True)
with open(log, "a", encoding="utf-8") as f:
f.write(f"{datetime.datetime.now().isoformat(timespec='seconds')}\t{data.get('cwd','')}\t{cmd}\n")
sys.exit(0)
"PostToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/log_bash.py" }] }
]
テスト結果:npm test → ログに2026-10-15T23:56:39 /p npm testの1行が追記された。
5. APIキー入りのプロンプトを送信前に止める(UserPromptSubmit)
エラーログを貼り付けたら、中にAPIキーが混ざっていた——という事故を防ぎます。Anthropic、OpenAI、Google、GitHub、AWSのキーらしき文字列を見つけたら、終了コード2でプロンプトの送信そのものを止めます。
# .claude/hooks/block_secrets_in_prompt.py
#!/usr/bin/env python3
"""UserPromptSubmit: APIキーらしき文字列を含むプロンプトを送信前に止める"""
import json, re, sys
PATTERNS = [r"sk-ant-[A-Za-z0-9_-]{20,}", r"sk-[A-Za-z0-9]{32,}", r"AIza[0-9A-Za-z_-]{35}",
r"ghp_[A-Za-z0-9]{36}", r"AKIA[0-9A-Z]{16}"]
data = json.load(sys.stdin)
prompt = data.get("prompt", "")
for p in PATTERNS:
if re.search(p, prompt):
print("APIキーらしき文字列が含まれています。削除してから送信してください。", file=sys.stderr)
sys.exit(2) # プロンプトを送らせない
sys.exit(0)
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/block_secrets_in_prompt.py" }] }
]
テスト結果:sk-ant-api03-で始まる文字列を含むプロンプト → 終了コード2で停止。「テストを書いて」 → 通過。
6. セッション開始時にブランチと変更状況を伝える(SessionStart)
セッションの最初に、今のブランチ名と未コミットの変更数をClaudeに伝えます。additionalContextはhookSpecificOutputの中に入れる必要があり、トップレベルに書くと無視される点に注意してください。
# .claude/hooks/session_context.py
#!/usr/bin/env python3
"""SessionStart: 現在のブランチと未コミットの変更をClaudeに伝える"""
import json, subprocess, sys
data = json.load(sys.stdin)
cwd = data.get("cwd", ".")
def git(*args):
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else ""
branch = git("branch", "--show-current")
if not branch:
sys.exit(0) # gitリポジトリでなければ何もしない
changed = git("status", "--short").splitlines()
ctx = f"現在のブランチ: {branch}。未コミットの変更: {len(changed)}件。"
print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart",
"additionalContext": ctx}}, ensure_ascii=False))
"SessionStart": [
{ "matcher": "startup|resume",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/session_context.py" }] }
]
テスト結果:変更が2件あるリポジトリ → 「現在のブランチ: master。未コミットの変更: 2件。」を返す。gitリポジトリでない場所 → 何もしない。
7. デバッグ用の出力が残ったまま終わらせない(Stop)
Claudeが応答を終えようとしたときに、今回追加した行にconsole.log(やdebugger;、breakpoint()が残っていれば、終了を止めて削除させます。
# .claude/hooks/stop_check_debug.py
#!/usr/bin/env python3
"""Stop: デバッグ用の出力が残っていたら、終わる前に消させる"""
import json, re, subprocess, sys
data = json.load(sys.stdin)
if data.get("stop_hook_active"):
sys.exit(0) # 既にこのhookで続行中なら止めない(無限ループ防止)
diff = subprocess.run(["git", "diff", "-U0"], cwd=data.get("cwd", "."),
capture_output=True, text=True).stdout
added = [l for l in diff.splitlines() if l.startswith("+") and not l.startswith("+++")]
hits = [l for l in added if re.search(r"console\.log\(|debugger;|breakpoint\(\)", l)]
if hits:
print(json.dumps({"decision": "block",
"reason": "追加した行にデバッグ用の出力が残っています: " + hits[0][1:80]},
ensure_ascii=False))
sys.exit(0)
"Stop": [
{ "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop_check_debug.py" }] }
]
テスト結果:console.log("dbg")を追加した状態 → blockと該当行を返す。stop_hook_active: true → 止めずに通す。
stop_hook_activeがtrueのとき(すでにこのhookの指示で続行中のとき)は止めない、というのが公式の推奨です。Claude Code側にも、ツール呼び出しを挟まない連続の続行は8回までという上限があります。8. 入力待ちになったらWindowsに通知する(Notification・WSL向け)
許可の確認や入力待ちで止まっているのに気づかない、という時間のロスを減らします。WSLからpowershell.exeを呼んで、Windowsの通知を出します。Notification hookは通知のための仕組みで、処理を止めることはできません。
# .claude/hooks/notify_wsl.py
#!/usr/bin/env python3
"""Notification: 入力待ちになったらWindowsの通知を出す(WSL向け)"""
import json, shutil, subprocess, sys
data = json.load(sys.stdin)
msg = data.get("message", "Claude Code が入力を待っています").replace("'", "''")
ps = shutil.which("powershell.exe")
if ps:
script = ("[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');"
"$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;"
f"$n.Visible=$true;$n.ShowBalloonTip(5000,'Claude Code','{msg}',0);Start-Sleep 6;$n.Dispose()")
subprocess.Popen([ps, "-NoProfile", "-Command", script])
sys.exit(0)
"Notification": [
{ "matcher": "permission_prompt|idle_prompt",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/notify_wsl.py" }] }
]
テスト結果:スクリプトが異常終了しないことは確認。ただし検証環境ではWindows側のpowershell.exeにPATHが通っておらず、通知が実際に表示されるところまでは確認できていません。macOSならosascript -e 'display notification …'、Linuxならnotify-sendが公式ガイドの例です。
うまく動かないときの確かめ方
- スクリプト単体に入力を流す:公式ドキュメントも勧める方法です。
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | python3 guard_bash.py; echo $?のように、hookが読むフィールドを含むJSONを渡し、終了コードと出力を確かめます。この記事のテストもこの方法で行いました。 /hooksで登録状況を見る:Claude Codeの中で/hooksと打つと、どの設定ファイルからどのhookが読み込まれているかを一覧できます(閲覧のみ)。- デバッグログを読む:
claude --debug-file /tmp/claude.logで起動し、別の端末でtail -fすると、hookの実行結果(終了コードや所要時間)が見えます。--debugだけだと画面には出ず、~/.claude/debug/に書かれます。 - 「Hook output does not start with {」が出たら:シェルの設定ファイル(
.bashrcなど)が標準出力に何かを出していて、JSONとして読めなくなっています。hookから呼ばれるときは何も表示しないようにします。
ハマりどころ
- Bashで書かれたファイルはEdit|Writeのhookでは捕まらない:
echo ... > .envのようにBash経由で書かれると、例1の保護はすり抜けます。重要なファイルは、Bashのhookや権限設定でも守ります。 ifフィールドは「なるべく」の絞り込み:"if": "Bash(git *)"のような条件は便利ですが、公式にも「ベストエフォート」とあります。確実な禁止には使わないでください。- タイムアウトすると止まらない:hookがタイムアウトしてもツール呼び出しはそのまま進みます。必ず止めたい場合は
"onFailure": "block"を指定します(v2.1.295以降)。 - 共有リポジトリのhookは中身を読んでから:hooksはあなたの権限で任意のコマンドを実行します。他人が書いた
.claude/settings.jsonやプラグインのhookは、信頼する前に必ず確認してください。
まとめ
hooksは「Claudeに守ってほしいルール」を、指示文ではなく仕組みで強制できる機能です。まずは例1(書き込み禁止)と例5(秘密情報の送信防止)のように、事故を防ぐものから入れるのが始めやすい順番です。そのうえで、例3や例7のように「直すところまでClaudeに任せる」使い方に広げると、確認の手間が目に見えて減ります。
関連記事:Claude Code Skillsの作り方 | MCPサーバーをPythonで自作してClaude Codeにつなぐ | エージェントを外部につなぐ前のチェックリスト | Microsoft Execution Containers:エージェントをポリシーで閉じ込める | 月数十セントのエージェント用サンドボックス「Agent37」
公式ドキュメント:Hooks reference | Hooks guide
最終確認日:2026年10月15日(Claude Code 2.1.300で仕様を確認)