Claude Code のフック設定を書こうとしているが、ツール周りのフック5兄弟(PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied)の使い分けが分からない人向け
PostToolUseFailureは「ツールが失敗した時に Slack 通知したい・失敗を Claude の次の手のヒントに変えたい」場面で <code>.claude/settings.json</code> に登録する。PermissionRequestは「危ない rm だけ人に確認させて、安全な npm test は素通りで通したい」「コマンドの中身を見て勝手に sanitize(安全な形に書き換え)してから許可したい」場面で同じく settings.json に登録して、JSON で decision.behavior を返すスクリプトを書く。
Claude Codeのフックには、Claudeがツールを叩こうとする瞬間に挟み込めるものが5種類ある。PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied の5兄弟だ。このページで扱うのはそのうち後ろの2つ。失敗を見届ける後始末役と、人間に「やっていい?」と聞かれる直前に割り込むゲート役です。
役割を1行で分けるとこう。PostToolUseFailure は「失敗した事実を観察してClaudeに次の手の材料を渡す」。PermissionRequest は「人間にダイアログが出る前に、自動でOK・NG・条件付きOKを決める」。止められるのは後者だけです。
噛み砕くと
会社で新人がコピー機を使う場面を思い浮かべます。新人が「この資料コピーしていいですか」と上司に確認してくる瞬間がある。これが PermissionRequest。先輩がそのやり取りを横から見て「いいよ」「ダメ」を代わりに即答できる。さらに「今後この種類の資料は黙ってコピーしていい」と社内ルールを書き足すこともできる。
一方、コピー機が紙詰まりで止まった瞬間に駆けつけてログを取り、「今度から両面印刷の時はこの紙種を避けて」と新人にメモを渡す係が PostToolUseFailure。紙詰まりそのものは止められない。すでに起きた失敗を観察して、次の指示の材料を作るだけです。
大事な前提:このフック2つは「同じツール呼び出しを別の瞬間で捕まえる」
5兄弟は tool_use_id という同じIDで紐付いている。1回のBash実行が PreToolUse(実行前)→ PermissionRequest(人間に出す直前)→ PostToolUse or PostToolUseFailure(実行後)と流れる中で、全イベントが同じ tool_use_id を持つ。後でログを突き合わせる時の鍵です。
そして表現力に決定的な差が出る部分が1つ。PermissionRequest は exit code 2 でも拒否扱いになるが、それだけだと message も updatedInput も permissionRules も渡せない。実用上はJSONで hookSpecificOutput.decision.behavior: "deny" を返して exit 0、というのが定石です。ユーザーへの説明文や、許可と引き換えにルールを書き足したい時はJSON経由じゃないと表現できない。
「ブログ用にClaude Codeで作業する時、危ない rm だけ人に確認させて、テスト失敗は Slack に飛ばす」を例に、実際の手順を見る
ブログ記事のソースを Claude Code 管理に乗せていて、Claudeに「画像を変換して」「テスト走らせて」「不要ファイル消して」などを頼む使い方を想定します。やりたいのは次の2つ。
rm -rfだけは必ず人間が確認する。npm testやnpm run buildは黙って通すnpm testが失敗した瞬間に、エラー内容を Slack に流す(人間が後で気づける)
ステップ1: 設定ファイルにフックを宣言する
プロジェクト直下の .claude/settings.json を開いて、hooks ブロックに2つのイベントを足します。
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/bash-gate.sh" }
]
}
],
"PostToolUseFailure": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/notify-test-failure.sh" }
]
}
]
}
}
これで Bash ツール(コマンドを叩くツール)で何か起きるたびに2つのスクリプトが呼ばれるようになります。
ステップ2: PermissionRequest 側のゲートを書く
.claude/hooks/bash-gate.sh をこう書きます。Claudeが叩こうとしたコマンドを tool_input.command で受け取って、内容を見て自動で許可・拒否を返す形です。
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
# rm -rf は人間に判断を委ねる: deny して message でユーザーに見せる
if echo "$cmd" | grep -qE '^rm[[:space:]]+-rf'; then
jq -n --arg c "$cmd" '{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "rm -rf は自動承認しない。手動で打ち直してください: \($c)"
}
}
}'
exit 0
fi
# npm test / npm run build は黙って通す
if echo "$cmd" | grep -qE '^npm[[:space:]]+(test|run[[:space:]]+build)'; then
jq -n '{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}'
exit 0
fi
# それ以外は何も返さない: 通常のダイアログを人間に見せる
exit 0
ポイントは exit code 2 ではなくJSON を返している こと。exit 2 だけでも拒否扱いにはなるけど、ユーザーに「なぜ自動承認しないか」を伝えるメッセージや、コマンドを書き換えて許可する updatedInput、以降聞かれなくなる permissionRules は全部JSONじゃないと書けない。ここで初心者がやりがちな勘違いがあって、他のフックの感覚で「拒否したいから exit 2 だけ返せばいいや」と書くと、deny は成立するんだけど何の説明もなく止まるのでClaudeが「なぜ通らない?」と混乱します。
ステップ3: 動作確認する
Claude Codeを立ち上げて、Claudeに「./node_modules を消してきれいにして」と頼みます。Claudeは rm -rf ./node_modules を叩こうとして、フックが拒否を返すので、ダイアログ自体が出ずに「rm -rf は自動承認しない」というメッセージが Claude 側に渡る。Claudeは「では手動で打ち直してください」と返してきます。
次に「テスト走らせて」と頼むと、Claudeは npm test を叩こうとして、フックが allow を返すのでダイアログは出ない。そのまま実行に進みます。
ステップ4: PostToolUseFailure 側の通知スクリプトを書く
テストが失敗した瞬間に Slack に流す係です。.claude/hooks/notify-test-failure.sh を次のように書きます。送信先のURLは SLACK_WEBHOOK_URL を ~/.zshrc か ~/.bashrc に export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/xxx' の形で書いておくと、Claude Code を起動したターミナル側の設定値がそのまま引き継がれて読み取られます。
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
err=$(echo "$input" | jq -r '.error // empty')
tid=$(echo "$input" | jq -r '.tool_use_id // empty')
# npm test の失敗だけに反応
if echo "$cmd" | grep -qE '^npm[[:space:]]+test'; then
curl -s -X POST -H 'Content-Type: application/json' \
-d "{\"text\": \"npm test 失敗 (id=$tid): $err\"}" \
"$SLACK_WEBHOOK_URL" >/dev/null
# Claude 側にも次の手のヒントを渡す
jq -n --arg e "$err" '{
"hookSpecificOutput": {
"hookEventName": "PostToolUseFailure",
"additionalContext": "テストが失敗しました: \($e)。失敗ログをSlackに通知済み。next step として該当テストファイルを開いて原因を特定してください。"
}
}'
fi
exit 0
ここで重要なのは additionalContext。これを返すと、Claudeの次のターンに「テスト失敗しました、Slack通知済み、次は該当テストファイルを開いて」という文字列がClaudeの目の前に追加されます。失敗を「ただ観測する」だけじゃなくて、Claudeの次の手の材料を渡せる。
ステップ5: 失敗をブロックしようとして失敗する(観察)
もし PostToolUseFailure の中で exit 2 を返したらどうなるか。試すと、stderr の内容は Claude に「参考情報」として渡るけど、すでに失敗しているコマンドそのものは再実行されない。PostToolUseFailure は「失敗を止める」のではなく「失敗の後始末をする」フックだからです。これは仕様。
ステップ6: tool_use_id でログを突き合わせる
動作確認の後、ログファイルや Slack の履歴で tool_use_id を見比べると、同じBash実行の PreToolUse → PermissionRequest → PostToolUseFailure が同じIDで流れていることが確認できます。1個のツール呼び出しを最初から最後まで追えるので、複雑なフック群を組んだ時のデバッグはこのIDが頼り。
つまり PostToolUseFailure / PermissionRequest は何をしてくれるのか
- PermissionRequest がやってくれる: 人間にダイアログが出る前に自動で許可・拒否を返す、コマンド内容を
updatedInputで書き換えて安全な形にして許可する、permissionRulesで「以降このパターンは聞かない」ルールを書き足す - PostToolUseFailure がやってくれる: 失敗のロギング、後始末スクリプトの起動、Claudeに
additionalContextで「次こうしてね」のヒントを渡す - やってくれない: PostToolUseFailure で失敗そのものを取り消すことはできない、PermissionRequest で exit 2 による拒否はできない、自動モードのように人間ダイアログが出ない設定下では PermissionRequest 自体が発火しない場面もある
- 意味が薄い場面: 1人で書き捨てのプロジェクトで全部 yolo モードで動かしている時。最初から人間確認をスキップしている運用ではゲート役のフックを書いても発火機会がない
使いどころ3シナリオ(具体題材で再現)
シナリオ1: 料理ブログのデプロイ自動化で「rm だけ手動・push は自動」を分けたいとき
家庭料理のレシピを毎週載せるブログを Claude Code で更新運用しているとします。Claudeに「画像を WebP に変換して、不要なPNGは削除、それから git push まで」と頼みたい。ただ rm 系だけは怖いので確認したい。PermissionRequest で「rm から始まるコマンドは deny、git push origin main と cwebp は allow」と書けば、Claudeの作業が止まらず、消す瞬間だけ手元に確認が回ってきます。permissionRules を返せば次回以降は git push でいちいち聞かれなくなる。
シナリオ2: 家計簿アプリの開発で「テスト失敗を見逃したくない」とき
夜中にClaudeに「家計簿の集計バグを直して、テストも追加して」と頼んで自分は寝るとする。朝起きて「全部終わってます」と言われるけど、実は途中で npm test が3回失敗していて、Claudeが勝手にテストを書き換えて通る形にしていた、みたいな事故が起きうる。PostToolUseFailure で npm test の失敗を全部 Slack に流しておけば、朝Slackを見るだけで「何回失敗したか・どんなエラーだったか」が時系列で並んでいる。Claudeの「終わりました」報告だけを信じずに済みます。
シナリオ3: 仕事の業務スクリプトで「危ない宛先のメール送信だけ止めたい」とき
顧客リストにメールを一斉送信する社内スクリプトをClaudeに叩かせるとします。テスト用の --dry-run 付きは黙って通したい、本番送信の --production 付きは絶対に人間確認を入れたい。PermissionRequest で tool_input.command を見て、--production が入っている場合は deny、--dry-run なら allow。さらに updatedInput で「コマンドの末尾に --limit 1 を強制追加して許可」というやり方もできる。送信するけど1件だけ、という形に勝手に絞り込んで通す運用です。
初心者が踏みやすい落とし穴
- PermissionRequest で exit code 2 を返すと拒否は成立するが説明が一切残らない。deny 自体は効く。ただし message・updatedInput・permissionRules といった「許可方の細かい制御」は全部JSONじゃないと渡せない。実用上は JSON で
hookSpecificOutput.decision.behavior: "deny"を返して exit 0、が事故りにくい書き方 - PostToolUseFailure は失敗を取り消せない。「テストが失敗したから直前のコマンドを再実行」みたいな処理を書こうとしても、ツール側はもう失敗確定。やれるのは
additionalContextでClaudeに次の手のヒントを渡すところまで - decision オブジェクトが hookSpecificOutput の中にネストされている。トップレベルに
"decision"を書いてしまうと PermissionRequest では効かない。hookSpecificOutput.decision.behaviorの形が正しい - updatedInput はツール毎にフィールド構造が違う。Bash なら
{"command": "..."}、Write なら{"file_path": "...", "content": "..."}。書き換えたいツールのtool_input構造を必ず合わせる。形が違うとClaudeが入力エラーで止まる - permissionRules は強力すぎる。一度
"Bash(git *)"みたいな広いルールを書き込むと、以降そのプロジェクトでgit系コマンドが全部素通りになる。狭いpatternを心がけて、"Bash(npm test)"のように特定コマンドに絞るのが安全 - PermissionRequest と PermissionDenied は別系統。前者は「人間ダイアログの直前」、後者は「自動モードで分類器が拒否した後」。混同して同じ目的に両方書くと挙動が読めなくなる
- フックスクリプトの実行権限を忘れる。
chmod +x .claude/hooks/*.shしておかないと、JSONを返す前にフック自体が起動失敗で stderr だけ出して終わる - jq がMacに標準で入っていない。スクリプトの中で
jqを使うなら先にbrew install jq。フックは静かに失敗するので「動いてない理由」が分かりにくい
書き方
.claude/settings.json の hooks ブロックに、PostToolUseFailure / PermissionRequest をイベント名として書く:
{
"hooks": {
"PermissionRequest": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": ".claude/hooks/bash-gate.sh" }] }
],
"PostToolUseFailure": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": ".claude/hooks/notify-test-failure.sh" }] }
]
}
}
スクリプト側は標準入力でJSONを受け取り、標準出力にJSONを返す。
やってみるとこうなる
入力
PermissionRequest の入力例(Claude が npm test を叩こうとした瞬間):
{
"session_id": "abc123",
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_use_id": "tooluse_123abc",
"permission_mode": "default"
}
PostToolUseFailure の入力例(npm test が exit 1 で落ちた直後):
{
"session_id": "abc123",
"hook_event_name": "PostToolUseFailure",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_use_id": "tooluse_123abc",
"permission_mode": "default",
"effort": { "level": "medium" },
"error": "Command failed with exit code 1",
"stderr": "npm ERR! Test suite: 3 failed, 12 passed"
}
両者は同じ tool_use_id を持つので、1回のツール呼び出しを最初から最後まで突き合わせて追える。
出力例
PermissionRequest で「許可」を返す:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}
PermissionRequest で「拒否+メッセージ」:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "rm -rf は自動承認しない。手動で打ち直してください"
}
}
}
PermissionRequest で「コマンドを書き換えて許可+以降の自動許可ルール追加」:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": { "command": "npm test -- --bail" },
"permissionRules": [{ "tool": "Bash", "pattern": "npm test*" }]
}
}
}
PostToolUseFailure で Claude に補足情報を渡す:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUseFailure",
"additionalContext": "テストが失敗しました。失敗ログは Slack に通知済み。該当テストファイルを開いて原因を特定してください。"
}
}
注意: PermissionRequest は exit code 2 でも拒否扱いにはなるが、message・updatedInput・permissionRules を渡したいなら必ず JSON で decision.behavior を返して exit 0。実用上はJSON経由が定石。
このページに出てきた言葉
- フック
- Claude Codeが何かする瞬間(ツール実行前、実行後、人間に質問する直前など)に割り込んで、自分のスクリプトを動かせる仕組み
- tool_use_id
- 1回のツール呼び出しに振られる識別ID。PreToolUse / PermissionRequest / PostToolUse / PostToolUseFailure の全イベントで同じ値が流れてくるので、1個のツール呼び出しを最初から最後まで突き合わせて追える
- hookSpecificOutput
- フックがJSONで返す結果をまとめるブロック。PermissionRequestなら decision.behavior(allow / deny)、PostToolUseFailureなら additionalContext(Claudeへの補足)などをここに入れる
- decision.behavior
- PermissionRequestで自動許可・拒否を決める値。<code>"allow"</code> なら人間ダイアログを出さずに即実行へ、<code>"deny"</code> なら拒否してメッセージを返す
- updatedInput
- PermissionRequest許可時に、Claudeが渡してきたコマンド内容を上書きできる仕掛け。「<code>rm -rf ./</code> を <code>rm -rf ./tmp</code> に絞り込んで許可」のような sanitize(安全化)に使う
- permissionRules
- PermissionRequestで「今後このパターンのコマンドは自動で許可」と書き足せるルール配列。settings.jsonのpermissionsと同じ形式で、1回許可すれば以降は同じパターンを聞かれなくなる
- additionalContext
- PostToolUseFailureが返せる文字列。Claudeの次のターンに補足情報として差し込まれて、Claudeが次の手を組み立てる材料になる
- matcher
- フックを発火させるツールの名前条件。<code>"Bash"</code> ならBashツールのみ、<code>"Edit|Write"</code> なら EditとWrite両方、<code>"mcp__.*"</code> なら全MCPツールが対象になる正規表現
- exit code 2
- スクリプトの終了コード2。Claude Codeのフックでは「ブロック扱い」として使われる慣習がある。PermissionRequestでも exit 2 は拒否扱いになるが、message・updatedInput・permissionRules を渡したいなら JSON で返す必要がある
- permission_mode
- Claude Codeが今どの権限モードで動いているかを示す値。<code>"default"</code> は通常モードで、危ないツール呼び出しは人間ダイアログで聞かれる