PreToolUse hookでツール実行前に許可・拒否・改変する
PreToolUseフックは、ツールが実行される直前に毎回発火し、許可・拒否・確認・保留の決定と入力の書き換えができる仕組みです。
PreToolUseフックは、Claudeがツールの入力を組み立てた直後、実際に処理される前に発火します。権限プロンプトの要不要にかかわらず毎回起動します。
そのため、rm -rfのような破壊的なBashコマンドを止める、書き込み先のパスを検証する、入力そのものを書き換える、といった用途に向きます。
フックイベント全体の一覧はHooks完全ガイドにあります。本記事はPreToolUseが受け取る情報と4通りの決定、実装で見落としやすい点に絞ります。
モデルがツール呼び出しをtool_useブロックではなく生テキストのまま出力する不具合が起きると、その呼び出しはPreToolUseを経由せず、実行もされません。詳細はClaude Codeでツール呼び出しが実行されない不具合と回避策にあります。
PreToolUseが発火するタイミング
PreToolUseはEndConversationを除くすべてのツール名にマッチします。組み込みツール(Bash・Edit・Write・Read・Glob・Grep・Agent・WebFetch・WebSearch・AskUserQuestion・ExitPlanModeなど)はもちろん、mcp__<server>__<tool>の形式で登録されるMCPツールも対象です。
MCPツールをmatcherで絞り込むときは.*を省略できません。mcp__memoryのように英数字・アンダースコア・ハイフンだけで書くと完全一致として評価され、どのツール名とも一致しないため一つも発火しません。mcp__memory__.*と書いて初めてmemoryサーバーの全ツールにマッチします。プラグインが同梱するMCPサーバーの場合はツール名にプラグイン名が挟まりmcp__plugin_<プラグイン名>_<サーバー名>__<ツール名>という形になるので、matcherもこのスコープ付きの名前で書く必要があります。
見落としやすいのは@でファイルを参照したときの挙動です。プロンプト中で@によって参照したファイルは、Claude Codeがプロンプト組み立ての段階で内容を直接埋め込むため、ツール呼び出しを経由しません。ReadにマッチするPreToolUseフックを書いても、この経路のファイル読み込みは素通りします。特定のパスを@参照ごと止めたい場合は、フックではなくReadのdenyルールを使います。
受け取るJSON
PreToolUseはsession_id・cwd・permission_mode・hook_event_nameなどの共通フィールドに加えて、tool_name・tool_input・tool_use_idを受け取ります。Bashコマンドの場合は次のような形です。
{
"session_id": "abc123",
"cwd": "/Users/you/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite",
"timeout": 120000,
"run_in_background": false
},
"tool_use_id": "toolu_01ABC123..."
}Write・Edit・Readではtool_input.file_pathが常に絶対パスで渡されます。Claude Codeがフック実行前に~や相対パスを展開済みなので、~や相対パスの書き方でパス比較をすり抜けられることはありません。ただしWindowsでは区切り文字がバックスラッシュのまま届きます。Git Bash上で動くフックで$PWDが/c/projectのように見えていても、tool_input.file_pathはC:\project\src\index.tsという形です。フォワードスラッシュ前提で/src/のような文字列比較を書くと一致せず、ブロックしたつもりのパスがすり抜けます。比較前にfile_path.replace("\\", "/")のように区切り文字をそろえ、絶対パスなので^アンカーではなく/src/のようなセグメント一致で判定するのが安全です。
4つの決定と優先順位
PreToolUseはhookSpecificOutputオブジェクトの中にpermissionDecisionを返すことで、他のフックより細かい制御ができます。
| permissionDecision | 効果 |
|---|---|
allow | 効果権限プロンプトをスキップする。ただしどのモードも自動承認しない操作と、AskUserQuestion・ExitPlanMode(updatedInputとの組み合わせが必要)は例外 |
deny | 効果ツール呼び出しを止める。permissionDecisionReasonはClaudeに渡る |
ask | 効果ユーザーに確認を求める。理由はユーザーにだけ表示され、Claudeには渡らない |
defer | 効果いったん処理を保留する(非対話モード専用) |
additionalContextに文字列を入れると、ツールの結果と並べてClaudeのコンテキストに追加されます。ただし決定がdeferのときは無視されます。
複数のフックが異なる決定を返した場合の優先順位はdeny > defer > ask > allowです。deny/askの権限ルールは、フックの決定にかかわらず引き続き評価されるため、フック側がallowを返しても既存のdenyルールに一致すれば拒否が優先されます。
exit 2で終了する実装はdenyと同じ経路を通り、Claudeはstderrのメッセージを拒否理由として受け取ります。JSONのpermissionDecisionが"allow"でも、exit 2なら拒否が優先されます。逆に、2以外の非ゼロ終了(exit 1など)は、有効なdenyのJSONを伴わない限り拒否になりません。トランスクリプトにhook errorの通知が出るだけで、呼び出しは進みます。標準出力がJSONとして壊れていた場合も同じ扱いです。askを返すと、権限プロンプトに[settings](設定ファイルまたはagent frontmatter由来)・[plugin:<name>](プラグイン)・[skill](skill frontmatter)のいずれかのラベルが付き、どの設定がこの確認を要求しているかが分かるようになっています。auto modeでもaskは強制的にプロンプトを出させます。分類器がツール呼び出しを拒否することはできますが、フックが要求した確認を黙って承認することはできません。
フックがタイムアウトしたとき、PreToolUseはどちらに倒れるか
通す側
timeout(既定600秒)に達するとキャンセルされ、出力は捨てられます。ツール呼び出しは通常の権限フローへ進むので、固まったフックをゲートの代わりにはできません。
止める側
タイムアウトするとツール呼び出しがブロックされ、Claudeにはタイムアウトを名指ししたエラーが返ります。ただし別のフックが返した明示的なdenyは、そちらが優先されます。
実装例 — 危険なコマンドを止める
Bashのif条件でrm *にマッチする呼び出しだけを絞り込み、rm -rfを含む場合にdenyを返すスクリプトです。設定側はmatcherでBash呼び出しに絞り、ifでさらにrm *だけに絞ることで、それ以外のコマンドではプロセスを起動せずに済みます。
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by hook"
}
}'
else
exit 0 # 決定なし。通常の権限フローに委ねる
fi設定は次のとおりです。argsを指定する実行形式(exec form)にするとシェルを介さず直接スクリプトを起動でき、${CLAUDE_PROJECT_DIR}のようなパスプレースホルダーをクォート忘れで壊す心配がありません。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh",
"args": []
}
]
}
]
}
}ifフィールドはPreToolUse・PostToolUse・PostToolUseFailure・PermissionRequest・PermissionDeniedの5イベントだけで評価され、permissionsと同じルール構文を使います。複合コマンドは部分ごとに照合され、npm test && rm -rf buildや$()の中のrmにもBash(rm *)は一致します。コマンド名より先まで指定したパターン(Bash(git push *)など)で$VARが絡む場合や、実行内容を確定できない入力では、パターンに関係なくフックが起動します。ifに書けるのは権限ルール1つだけで、&&・||・リスト構文はありません。Edit(src/**)は作業ディレクトリ直下のsrcだけに一致し、v2.1.214より前は深さを問わず一致していました。判定はベストエフォートです。
スクリプトのパスが間違っていたり実行権限(chmod +x)が無かったりすると、シェルは127などの終了コードで終わり、多くのイベントでは操作がそのまま進みます。settings.jsonのパスを打ち間違えると、ゲートが黙って無効になります。ポリシー用のフックは、初回実行で通知が出ないかを見ておきます。
実機で試す — このスクリプトが止められる範囲
フックのスクリプトは標準入力にJSONを流すだけで単体テストできます。上のblock-rm.shを、Claude Code v2.1.287を入れた環境でrmの書き方を変えて流した結果が次のとおりです。
$ echo '{"tool_input":{"command":"rm -rf build"}}' | .claude/hooks/block-rm.sh
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
$ echo '{"tool_input":{"command":"rm -r -f build"}}' | .claude/hooks/block-rm.sh
$ echo '{"tool_input":{"command":"rm -fr build"}}' | .claude/hooks/block-rm.sh
$grep -q 'rm -rf'は文字列がそのまま入っているかだけを見るので、フラグの順や分割が違うだけで一致せず、通常の権限フローに回ります。
フラグの並びを吸収するなら、再帰指定のオプションを正規表現で拾います。
if echo "$COMMAND" | grep -Eq '(^|[;&| ])rm +(-[a-zA-Z-]+ +)*-[a-zA-Z]*[rR]'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Destructive command blocked by hook"}}'
fiこの正規表現はrm -rf・rm -r -f・rm -fr・ls && rm -Rf tmpをdenyにし、rm notes.txtは通しました。rm --recursive buildは拾えず、残ります。文字列照合で網羅するのは難しいので、止めたい対象が決まっているなら権限のdenyルールと併用します。
updatedInputで入力を書き換える
updatedInputはツールの入力パラメータをまるごと置き換えます。変更しないフィールドも含めて指定しないと消えてしまう点に注意します。Claude Codeは、権限ルールとBashコマンドの自動バックグラウンド適格性を、Claudeが送った元の入力ではなくupdatedInputで返した入力に対して評価します。つまり書き換え後の値が別のdenyルールに一致すれば、allowを返していてもその後段で拒否に回ります。allowと組み合わせれば自動承認、askと組み合わせればユーザーに修正後の入力を見せてから確認できます。
AskUserQuestion・ExitPlanModeはこの仕組みの特殊なケースです。非対話モードの-pフラグ実行では、Agent SDKのcanUseToolコールバックのような「permission host」がない限りこの2つのツールを提供できません。フックがstdinから入力を読み、独自UIで回答を集めてupdatedInputに詰めてallowとともに返すことで、プロンプトなしにツールを実行させられます。AskUserQuestionの場合は元のquestions配列をそのまま返しつつ、質問文をキーにしたanswersオブジェクトを追加します。
deferで呼び出しを保留する
deferはclaude -pをサブプロセスとして起動しJSON出力を読み取るような統合(Agent SDKアプリや独自UI)向けの決定です。非対話モードの-pフラグでだけ有効で、インタラクティブセッションでは警告を出して無視されます。典型的なのはAskUserQuestionで、ターミナルが無いのでその場では答えられない状況です。
deferで保留して再開するまで
- 1
PreToolUseが発火する
Claudeが
AskUserQuestionを呼び出し、PreToolUseが発火します。 - 2
フックがdeferを返す
ツールは実行されず、
stop_reason: "tool_deferred"でプロセスが終了します。保留中の呼び出しはtranscriptに残ります。 - 3
呼び出し元が質問を表示する
呼び出し元プロセスがSDK結果の
deferred_tool_use(ツールのid・name・input)を読み取り、自前のUIで回答を集めます。 - 4
同じセッションを再開する
同じpermission hostを付けて
claude -p --resume <session-id>を実行すると、同じツール呼び出しが再びPreToolUseを発火させます。 - 5
フックが回答を詰めてallowを返す
updatedInputに回答を入れてallowを返すと、ツールが実行されてClaudeが続行します。
deferはClaudeがそのターンで1回だけツールを呼び出した場合にのみ有効です。複数のツール呼び出しをまとめて行った場合は警告とともに無視され、通常の権限フローに戻ります。バッチの中から1件だけを保留して他を宙に浮かせる方法がないためです。タイムアウトや再試行回数の上限もなく、セッションはcleanupPeriodDaysの保持期間(既定30日)が過ぎるまでディスク上に残ります。再開時に保留したツールが使えなくなっていると(提供元のMCPサーバーが未接続など)、フックが走る前にstop_reason: "tool_deferred_unavailable"とis_error: trueで終了します。
落とし穴
exec formとshell formの取り違え
argsを指定すると実行形式(exec form)になり、commandはPATH上の実行ファイルとして直接起動されるためシェルを介した引用符解釈が起きません。argsを省略するとシェル形式(shell form)になり、パイプや&&が使えますが、パス中のスペースや特殊文字はダブルクォートで囲む必要があります。パスプレースホルダーを使うフックは、原則としてexec formにしてargsへ渡すほうが安全です。
Windowsの.cmd/.batシム
npmやnpxがインストールするnode_modules/.bin配下の.cmd・.batシムは実行ファイルではないため、exec formではシェルなしに起動できません。nodeを直接呼び出して対象スクリプトを渡すか、シェル形式でシムを名前で呼び出す必要があります。
複数ハンドラは並列実行される
同じイベントにマッチする複数のフックハンドラはすべて並列で実行されます。同じハンドラを複数の設定ファイルに定義しても1回しか実行されませんが、プラグインやskillが持つ同名ハンドラは別物として扱われ、それぞれ実行されます。
PreToolUseとPermissionRequestの使い分け
| 用途 | 向くフック | 理由 |
|---|---|---|
| 権限の要不要に関係なく毎回検証したい | 向くフックPreToolUse | 理由呼び出しのたびに発火するため判定の抜け漏れがない |
| 確認ダイアログが出る直前だけ処理を挟みたい | 向くフックPermissionRequest | 理由すでにallowルールに一致する呼び出しでは起動せず軽量 |
| 特定コマンドの失敗時だけリカバリしたい | 向くフックPostToolUseFailure | 理由ツール実行後の結果を見て判断するイベントのため |
| 次回から確認なしで許可するルールを追加したい | 向くフックPermissionRequest | 理由updatedPermissionsで権限ルールそのものを書き込める |
まとめ
ポリシー用途のフックは、exit 1で書かずexit 2かdenyのJSONで拒否し、初回実行でhook errorが出ていないかを確かめます。この2点を外すと、フックは黙って素通しになります。ユースケース別の実装はHooks実例カタログにあります。スクリプトを書かずに禁止コマンドを止めたいなら、hookifyプラグインの規則ファイルも選択肢です。