PermissionDenied hookでauto mode拒否時の再試行を制御する
PermissionDenied hookはauto modeが拒否した瞬間だけ発火します。reasonの読み方とretry指定での再試行、PreToolUseとの違いを実例で見ます。
Claude CodeのPermissionDenied hookは、auto modeがツール呼び出しを拒否した直後にだけ発火するフックです。拒否理由をログに残す、設定を調整する、モデルに再試行を許可する、の3用途に使えます。手動の許可ダイアログを拒否したときや、PreToolUse hookがブロックしたとき、denyルールに一致したときは発火しません。
PermissionDenied hookが捕まえるのはauto modeの拒否だけ
このフックが動くのは、auto modeの分類器がツール呼び出しを拒否した場合に限られます。分類器がルールに一致したと判定した通常の拒否だけでなく、分類器が判定を出せなかった「no-verdict」な拒否も含みます。分類器のレスポンスが解析できなかった場合や、分類器へのリクエスト自体を別の安全チェックが拒んだ場合がこれに当たります。
対象を狭く見ておくことが重要です。あなたが手動で許可ダイアログを「いいえ」にしたときは発火しません。PreToolUse hookがexit code 2でツール呼び出しをブロックしたときも発火しません。denyルールに一致して弾かれたときも同様です。auto modeの分類器が拒否を出した瞬間だけが対象です。
matcherの対象はPreToolUseと同じツール名
matcherフィールドはツール名でフィルタします。PreToolUseやPermissionRequestと同じ対象です。
{
"hooks": {
"PermissionDenied": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/log-denial.sh"
}
]
}
]
}
}matcherを省略するか"*"にすると全ツールが対象になります。ifフィールドにBash(git *)のような権限ルール構文を書けば、matcherに一致したうち特定のコマンドだけにフックを絞れます。Edit|Writeのように|または,で複数ツールを列挙することもできます。mcp__memory__.*のような正規表現でMCPサーバー配下のツールをまとめて拾うことも可能です。カンマ区切りと前後の空白を許容する書き方はClaude Code v2.1.191以降が必要で、それより前のバージョンではカンマを含む文字列が正規表現として評価されます。
設定を書く場所によって効く範囲が変わります。.claude/settings.jsonはプロジェクト全体、~/.claude/settings.jsonはあなたの全プロジェクト、.claude/settings.local.jsonはgitignore対象のローカル専用設定です。プラグインのhooks/hooks.jsonやSkill・Sub agentのfrontmatterに書けば、そのプラグインが有効な間、またはそのSkill・Sub agentが動いている間だけ効く狭いスコープにできます。組織全体に強制したい拒否ログを仕込むなら、managed policy settingsに置く選択肢もあります。設定はレベルをまたいで上書きではなく合算されるので、プロジェクトの.claude/settings.jsonに書いたPermissionDenied hookと、あなたの~/.claude/settings.jsonに書いた別のPermissionDenied hookは両方とも発火します。
拒否をログに残す実装例
command hookはJSON入力を標準入力で受け取ります。jqでtool_nameとreasonを抜き出し、ファイルに追記するだけの最小構成です。
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
reason=$(jq -r '.reason' <<<"$input")
echo "$(date -Iseconds) tool=$tool reason=$reason" >> ~/.claude/denials.log
exit 0exit 0で終えれば拒否自体には影響しません。標準出力に何も書かなければ、単なるログ処理として扱われます。
ログと再試行判断は1つのスクリプトにまとめられます。次は、reasonが[で始まる通常拒否のときだけretry: trueを返す例です。
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
reason=$(jq -r '.reason' <<<"$input")
echo "$(date -Iseconds) tool=$tool reason=$reason" >> ~/.claude/denials.log
if [[ "$reason" == \[* ]]; then
jq -n '{hookSpecificOutput: {hookEventName: "PermissionDenied", retry: true}}'
fi
exit 0このスクリプトに、公式のサンプル入力に近い形のJSONを2件流して動かすと、次のようになります(jq 1.7.1、ログの出力先だけ作業ディレクトリに変えています)。通常拒否にはretry指定のJSONが返り、Classifier unavailableの拒否はログだけで終わります。
$ echo '{"tool_name":"Bash","reason":"[Irreversible Local Destruction]"}' | bash log-denial.sh
{
"hookSpecificOutput": {
"hookEventName": "PermissionDenied",
"retry": true
}
}
$ echo '{"tool_name":"Bash","reason":"Classifier unavailable"}' | bash log-denial.sh
$ cat denials.log
2026-10-04T22:31:22+09:00 tool=Bash reason=[Irreversible Local Destruction]
2026-10-04T22:31:22+09:00 tool=Bash reason=Classifier unavailableこれは入力を手で流した動作で、auto modeで実際に拒否を起こしたわけではありません。本番に入れる前に、拒否が出たセッションでもdenials.logに同じ形で行が増えるかを見ておくと安心です。
拒否の通知とフックの入力は何が違うか
auto modeの拒否は画面にも出ますが、ツール呼び出しの中身までそろって見える場所は多くありません。
拒否を確かめられる3つの場所
入力欄の近くの通知
bash denied by auto mode · [Data Exfiltration] · /permissionsのように、ツール名と理由だけが出ます。コマンドやURLは含まれません。Recently deniedタブ
/permissionsで開きます。シェルコマンドは、コマンドそのものでなくClaudeが付けた説明文で並びます。rキーで再試行を指定できます。PermissionDenied hook
拒否されたツール呼び出しの入力が
tool_inputとしてそのまま届きます。rm -rf /tmp/buildのような実際のコマンドをログに残せるのは、この経路です。
拒否の再現やallowルールの設計では、コマンドの全文が要ります。フックでtool_inputを残しておくと、あとから何が拒否されたかを引けます。
Sub agentがツールを呼んで拒否された場合も同じフックが発火します。この場合の入力にはagent_idとagent_typeが追加され、どのSub agentが何を拒否されたかをログ側で区別できます。並列で複数のSub agentを動かすワークフローでは、この2フィールドがないと拒否の発生源を後から特定できません。
コマンドの代わりにHTTPエンドポイントへ飛ばすこともできます。PermissionDenied hookのHTTPフックは、2xxで空のボディを返せばexit 0相当、2xxでJSONオブジェクトを返せば同じhookSpecificOutputスキーマとして解釈されます。非2xxや接続失敗はエラーとして扱われますが、拒否自体は覆らずそのまま進みます。社内の監査ログサービスへ拒否イベントを転送したいときに向く構成です。
reasonフィールドで拒否理由を3パターンに分ける
PermissionDenied hookは共通フィールドに加えてtool_name・tool_input・tool_use_id・reasonを受け取ります。
{
"hook_event_name": "PermissionDenied",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build",
"description": "Clean build directory"
},
"tool_use_id": "toolu_01ABC123...",
"reason": "[Irreversible Local Destruction]"
}reasonの中身は大きく3パターンに分かれます。
| reasonの形 | 意味 | 次にやること |
|---|---|---|
[ルール名](例 [Data Exfiltration]。ほとんどのセッションでこの形) | 意味分類器が該当ルールに一致と判定した通常の拒否 | 次にやること一時的な意図ならモデルに再指示、恒常的ならautoMode.environmentかallowルールを追加 |
Auto mode could not evaluate this action and is blocking it for safetyで始まる文 | 意味分類器の応答が解析できなかった拒否、または別の安全チェックが分類器のリクエストを拒んだ拒否(no-verdict) | 次にやること解析失敗なら再試行。安全チェックの拒否は会話内容が原因で、モデルは再試行でなく別のタスクへ進む。claude --debugで詳細を確認できる |
Classifier unavailable(固定文字列) | 意味分類器モデルが一時的に使えない | 次にやること数秒待って再試行。auto modeの利用可否設定とは無関係 |
[ルール名]型の拒否は/permissionsのRecently deniedタブにも記録されます。一方でno-verdict型の拒否はこのタブに記録されません。UI経由の再試行が効かないぶん、プログラムで拾う手段としてPermissionDenied hookの価値が上がります。
拒否が重なると、auto modeは一時停止してプロンプトに戻ります。基準は3回連続または合計20回で、設定では変えられません。別の安全チェックが分類器リクエストを拒んだ拒否は、この両方に数えられません。別の安全チェックによる拒否は自動では止まらないので、件数はこのフックで自前に集計する選択肢があります。繰り返し同じ宛先で拒否が出るなら、許可ルールの書き方はClaude CodeでArduinoを制御する記事の例が参考になります。
retry: trueでモデルに再試行を伝える
PermissionDenied hookはJSON出力のhookSpecificOutput.retryでモデルへの再試行許可を伝えられます。
{
"hookSpecificOutput": {
"hookEventName": "PermissionDenied",
"retry": true
}
}retryをtrueにすると、Claude Codeは会話に「そのツール呼び出しを再試行してよい」というメッセージを追加します。拒否そのものをフック側が覆すわけではありません。JSONを返さない場合やretry: falseの場合は、拒否がそのまま維持され、モデルは元の拒否メッセージを受け取ります。
PermissionDenied hookでは、他の多くのフックイベントと違ってexit codeとstderrが無視されます。拒否はすでに起きた後なので、exit 2でブロックする余地がありません。決定を伝える手段はJSON出力のretryだけです。
retryを返せるのはcommandフックなどのJSONを出せるハンドラです。prompt・agentフックもこのイベントで動きますが、出力は捨てられ、retryは設定できません。
PreToolUseやPermissionRequestとどう違うか
同じ「権限」に関わるフックでも、いつ・何に対して動くかが違います。
| フック | いつ発火するか | ブロックできるか | 主な用途 |
|---|---|---|---|
| PreToolUse | いつ発火するかツール呼び出しの前、権限判定より前 | ブロックできるかできる(exit 2でブロック) | 主な用途独自ルールでの事前ブロック・入力の書き換え |
| PermissionRequest | いつ発火するか権限プロンプトが表示される前 | ブロックできるかできる(decisionオブジェクトのみ。exit 2は無効) | 主な用途承認・拒否ロジックの自動化 |
| PermissionDenied | いつ発火するかauto modeが拒否した直後 | ブロックできるかできない(exit code・stderrは無視) | 主な用途拒否のログ化、モデルへの再試行許可 |
似た場面のNotification hookと、発火のタイミングを並べると次のとおりです。
承認待ちの通知と、拒否後の処理
Notification の permission_prompt
許可プロンプトが約6秒待たれた時点で発火します。承認待ちを人に知らせる用途です。
PermissionDenied
auto modeが自動で拒否を出した後にだけ発火します。ログ化と再試行の許可を担当します。
command・http・mcp_toolいずれのハンドラも、timeoutに達するとasync: true指定でない限りキャンセルされ、出力は破棄されます。PermissionDenied hookがタイムアウトすると、そのハンドラのretry指定は届かず、拒否はそのまま維持されます。ログ処理を遅い外部APIに依存させる場合は、タイムアウト時間を余裕を持って設定しておくと取りこぼしを防げます。
PreToolUse hookの入力フィールドやupdatedInputによるツール入力の書き換えなど、事前ブロック側の詳細はPreToolUse hookの記事にまとまっています。分類器がどの基準でルールに一致と判定するかはauto mode分類器の記事に、既定でブロックされる操作の一覧はauto modeブロック一覧にあります。ここで扱うのは、その判定結果を受け取って何をするかというフック側の挙動です。
よくあるつまずき
- exit codeでブロックしようとする: PermissionDenied hookはexit codeを無視します。拒否を覆したいのではなく、拒否後の挙動を制御したいだけなら
retryをJSONで返します - no-verdictの拒否にretryを使い続ける: 分類器が判定不能だった拒否では
retry: trueは無視されるため、単純なリトライスクリプトでは復旧しません。auto modeの安全性判断エラーで保留時の挙動を先に押さえておくと切り分けが早くなります - 手動拒否やPreToolUseブロックも拾えると思い込む: このフックはauto modeの拒否専用です。マニュアルモードでの拒否や別フックによるブロックはPermissionDenied hookの対象外で、別途
PreToolUseやNotificationで拾う必要があります - matcherのハイフン扱いを誤解する:
matcherはツール名の完全一致かJavaScript正規表現として評価されます。ハイフンを含む名前の完全一致扱いはClaude Code v2.1.195以降が必要で、それより前のバージョンでは正規表現として評価され意図せず広く一致することがあります
まとめ
ヘッドレス実行やCIでは、画面の通知もRecently deniedタブも見る人がいません。そういう環境でauto modeを使うなら、reasonとtool_inputをログに残すフックを先に入れておくと、拒否の原因を後から引けます。retryは通常拒否にだけ効くので、再試行を自動化するときも、no-verdictの拒否は人かモデルの判断に任せます。