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を省略するか"*"にすると全ツールが対象になります。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で終えれば拒否自体には影響しません。ログを残しつつ、必要な条件のときだけretry: trueを含むJSONを標準出力に書けば、ログと再試行判断を1つのスクリプトにまとめられます。標準出力に何も書かなければ、JSONとして解釈されず単なるログ処理として扱われます。
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の価値が上がります。
安全チェックが分類器リクエスト自体を拒んだno-verdictの拒否は、auto modeの一時停止しきい値にもカウントされません。頻発しても自動でマニュアルモードへ落ちないため、件数を監視したいならこのフックでの計測が唯一の手段になります。
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だけです。
PreToolUseやPermissionRequestとどう違うか
同じ「権限」に関わるフックでも、いつ・何に対して動くかが違います。
| フック | いつ発火するか | ブロックできるか | 主な用途 |
|---|---|---|---|
| PreToolUse | いつ発火するかツール呼び出しの前、権限判定より前 | ブロックできるかできる(exit 2でブロック) | 主な用途独自ルールでの事前ブロック・入力の書き換え |
| PermissionRequest | いつ発火するか権限プロンプトが表示される前 | ブロックできるかできる(decisionオブジェクトのみ。exit 2は無効) | 主な用途承認・拒否ロジックの自動化 |
| PermissionDenied | いつ発火するかauto modeが拒否した直後 | ブロックできるかできない(exit code・stderrは無視) | 主な用途拒否のログ化、モデルへの再試行許可 |
似た場面で使うNotification hookのpermission_promptと混同しやすい点にも触れておきます。permission_promptはマニュアルモードなどで許可プロンプトが約6秒待った時点で発火する通知で、承認待ちを人に知らせる用途です。PermissionDenied hookはその逆で、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以降が必要で、それより前のバージョンでは正規表現として評価され意図せず広く一致することがあります
まとめ
PermissionDenied hookは、auto modeの拒否を人間の目を介さずプログラムで拾いたいときに向きます。/permissionsのRecently deniedタブを都度開いてrキーで再試行を指定する手間を、ヘッドレス実行やCI環境でも同じ形にできます。reasonの形で通常拒否とno-verdictを見分け、retryは通常拒否にだけ効くと理解しておけば、ログ収集と再試行制御の両方を1つのフックにまとめられます。繰り返し同じ宛先で拒否が出る場合は、フック側の対処だけでなくautoMode.environmentへの追記やallowルールの追加も合わせて検討する対象です。拒否の検知はフックに任せても、設定を直すかどうかの最終判断は人が見てから行うほうが安全です。