Max Nardit
← 記事
15 mindev

Claude Code のフック:誘導には強く、関門としては弱い

フックはモデルが同意するかどうかに関係なく実行されます。そのためフォーマットのような雑用や、エージェントを望むルートへ誘導する用途には向いています。関門としては見た目より弱く、指定したツールしか検査せず、チェック自体が壊れるとブロックを明示的に有効にしない限り呼び出しは通り、構成によっては、フックがブロックした呼び出しを mod が承認できてしまいます。

目次

Claude Code のフックは設定ファイルに登録するコマンドで、Claude Code はセッション中の決まったタイミングでそれを実行します。ツール呼び出しの前と後、Claude が停止するとき、ユーザーの対応が必要になったときです。フックを実行するかどうかをモデルが決めることはありません。それが魅力であり、同時に、フックを安全層として使い始めた人がドキュメントが保証する以上にフックを信頼してしまう理由でもあります。

筆者自身の記録にも両方の面があります。2月の時点で、筆者のエージェント環境では Bash ガード、毎回のプロンプトに付くコンテキストフック、そして一部のエージェントではターンの終わりごとの LLM チェックが動いていました。4月にはガードが削除され、そのコミットメッセージには「ineffective, agents bypass via Write/Edit」(効果なし、エージェントが Write/Edit で迂回する)と書かれています。このハウツーでは、フックが得意なこと、フェイルクローズするフックの書き方、そしてフックからは決して見えない経路を扱います。以下のコマンドは Linux 上の Claude Code 2.1.310 で実行しました。ドキュメントだけに基づく記述には、その旨を明記しています。

Claude Code のフックとは何か、どのイベントでブロックできるのか?

フックはイベント、matcher、ハンドラーの組み合わせで、ハンドラーが何かを止められるのは一部のイベントだけです。PreToolUse はツール呼び出しをブロックできます。PostToolUse はツールがすでに実行済みなのでブロックできません。

設定は3階層の入れ子になっています。イベント(PreToolUse、Stop)、それを絞り込む matcher グループ(ツールイベントの場合は Bash や Edit|Write のようなツール名)、そして1つ以上のハンドラーです。ほとんどの構成ではハンドラーはシェルコマンドですが、HTTP エンドポイント、MCP ツール、単一の LLM プロンプト、サブエージェントにもできます。リファレンスには30以上のイベントが載っています。多くの構成で使われるのは次のものです。

per session:     SessionStart, SessionEnd
per turn:        UserPromptSubmit, Stop, StopFailure
per tool call:   PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure
on the side:     Notification, PreCompact, PostCompact, FileChanged, ConfigChange

コマンドフックはイベントを JSON として stdin から読み取り、exit code と stdout で応答します。何も出力しないか、プレーンテキストを出力するフックの場合、ルールは単純です。exit 0 は異議なし、exit 2 はブロック、それ以外のコードはブロックしないエラーで、Claude Code は hook error の通知を表示し、呼び出しは通常の権限チェックへと進みます。exit 2 の効果はイベントによって異なります。リファレンスにはイベントごとの表があり、多くの構成は次の行でカバーできます。

PreToolUse          Yes  Blocks the tool call
UserPromptSubmit    Yes  Blocks the prompt, so it never reaches Claude
Stop                Yes  Prevents Claude from stopping, continues the conversation
PostToolUse         No   Shows stderr to Claude; the tool already ran
Notification        No   Exit code and stderr are ignored
SessionStart        No   Shows stderr to user only

より細かく制御したい場合、フックは exit 0 で終了して JSON オブジェクトを1つ出力します。このときフィールドは正しい階層に置く必要があります。permissionDecision をトップレベルに置くと、Claude Code はエラーを出さずに無視します。PreToolUse では判断を hookSpecificOutput の中に入れます。

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use the deploy script, not a raw push"
  }
}

判断は allow、deny、ask、defer(最後のものは claude -p の実行時のみ)のいずれかで、その隣に updatedInput を置くと、呼び出しの実行前にツールの引数を置き換えます。同じ呼び出しに複数のフックが応答した場合は、deny、defer、ask、allow の順で最も制限の強い応答が優先されます。フックが有効な JSON を出力した時点で、判断するのは JSON です。exit 1 で終了しても有効な deny を出力していれば、呼び出しは拒否されます。

Claude Code のフックを初めて書くにはどうすればよいか?

stdin を読むスクリプトを書き、.claude/settings.json のイベントの下に登録し、Claude Code から呼ばれる前に JSON をパイプで渡してテストします。次の例は、マーカーとなるコマンドをブロックするガードです。

shell
#!/bin/bash
# .claude/hooks/guard-bash.sh
set -euo pipefail
input=$(cat)
cmd=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["tool_input"].get("command",""))' <<<"$input")
if [[ "$cmd" == *marker-deny* ]]; then
  echo "Blocked by guard-bash.sh" >&2
  exit 2
fi
exit 0

chmod +x で実行可能にしてから登録します。プロジェクトの .claude/settings.json にはそのプロジェクト用のフックを書き、コミットもできます。~/.claude/settings.json には全プロジェクト共通のフックを書きます。

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
            "onFailure": "block" }
        ]
      }
    ]
  }
}

まずスクリプト単体でテストします。

shell
echo '{"tool_name":"Bash","tool_input":{"command":"touch marker-deny-x"}}' \
  | CLAUDE_PROJECT_DIR=$PWD .claude/hooks/guard-bash.sh; echo "exit=$?"
# Blocked by guard-bash.sh
# exit=2

Claude Code の中では、/hooks で読み込まれたすべてのフックと、その読み込み元のファイルを一覧できます。実行時の挙動を見るには、Claude Code を --debug-file 付きで起動して Hooks: の行を読みます。そこにはイベント、コマンド、終了ステータス、所要時間が記録されます。まさにこのスクリプトとこの設定で、Claude に touch ok-file と touch marker-deny-file の2つの Bash 呼び出しを頼みました。ログには両方が残り、存在するのは1つ目のファイルだけです。

Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 0 (25ms)
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 2 (25ms)

ドキュメント自身の例は jq で入力をパースしており、筆者が最初に書いたガードも set -euo pipefail なしで同じようにしていました。テストしたマシンには jq が入っていません。ガードは stderr に jq: command not found を出力し、cmd は空になり、スクリプトは exit 0 に到達し、Claude Code はきれいな status 0 を記録しました。Claude はガードが止めるためにあったコマンドを実行し、ガードが壊れていることは画面のどこにも表示されませんでした。strict モードにすると終了の仕方が変わり、パーサーがなければスクリプトは exit 127 で終了します。公開したスクリプトの python3 の呼び出しを存在しないバイナリ名に変えて確認したところ、onFailure を設定した状態では呼び出しが拒否されました。

Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 127 (16ms)
[WARN] Hooks: PreToolUse command hook failed; blocking (onFailure: "block")

動かす価値のある Claude Code のフックのパターンは?

実行し損ねてもコストが小さく、確実に動けば毎回1ステップ省けるものです。フォーマット、通知、コンテキスト、そして Claude が停止する前のチェックです。リストの中で最もリスクが高いパターンはガードで、その理由は最後の3つのセクションで具体的に説明します。

フォーマットはドキュメントの最初の例で、筆者もここから始めることを勧めます。Edit|Write に対する PostToolUse フックが、Claude が触ったばかりのファイルにフォーマッターをかけます(ドキュメントからの引用で、ここではテストしていません)。

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
        ]
      }
    ]
  }
}

これは Claude が Bash 経由で変更したファイルを取りこぼします。何が触っても常にフォーマットされていなければならないファイルについては、ドキュメントは FileChanged フックを勧めています。

permission_prompt や idle_prompt のような matcher を付けた Notification フックは、Claude がユーザーを必要としているときに、notify-send、osascript、あるいはスマートフォンにメッセージを送るスクリプトで知らせてくれます。作業を終えたエージェントや待機中のエージェントを知らせるフックについては、tmux から Claude Code を操作するで詳しく扱いました。

コンテキストは戻せます。SessionStart では、コマンドのプレーンな stdout が Claude のコンテキストに追加されます。compact matcher はコンパクションの直後に発火し、これはプロジェクトの規約が要約から抜け落ちやすいタイミングです。UserPromptSubmit でも同じように毎回のプロンプトにコンテキストを追加できます。筆者のフックは現在時刻を注入していました。その履歴で最初の修正は出力形式で(フラットな additionalContext フィールドは、hookSpecificOutput の下に移すまで何の効果もありませんでした)、その後のコミットは頻度に関するものでした。エージェントごとのスロットリング、続いて間隔を30分から5分に変更です。フックが毎回のプロンプトに注入するものは、毎回のプロンプトでコストがかかります。また UserPromptSubmit のコマンドフックのデフォルトのタイムアウトは、通常の10分ではなく30秒です。変化しないコンテキストについては、ドキュメントはフックより CLAUDE.md を勧めています。

Stop フックは、Claude が停止する前に作業をチェックし、理由を添えて差し戻せます。prompt フックとして使うと、モデルがそのターンを判定し、ok を false にして理由を返すことで作業を続けさせます。筆者のフックはエージェントのターンを3つの違反についてチェックしていて、その1つはテストが失敗しているのに「完了」と主張することでした。履歴には判定役からの不正な JSON と、事前に知っておく価値のある2つの分類の問題が記録されています。最初のバージョンは会話の前半の違反まで検出してしまい、最新のターンだけを判定するようモデルに指示して修正しました。後のバージョンでは、フィードバックを分析して質問をしただけのエージェントを、修正したと主張したかのように検出してしまい、ルールを明示的な完了の言葉に絞りました。Stop の判定役は分類器であり、分類器と同じようにチューニングが必要です。そしてプロンプトは不変条件ではないで論じたように、関門に置いたモデルがしていることも、結局は判断であって強制ではありません。エージェントを作業の完了へ押し戻す用途なら、判断で十分です。

Stop について知っておくべき仕組みが2つあります。このフックは Claude が応答を終えるたびに発火し、タスクの終わりだけではありません。そしてブロックするフックは Claude を差し戻し続けるので、間にツール呼び出しを挟まずに8回連続でブロックすると、Claude Code はフックを無視します。入力には stop_hook_active が含まれ、Claude がすでに Stop フックによって作業を続けている場合は true になります。そのときは exit 0 で終了し、フックが自分自身の続行をブロックしないようにします。

時間のかかる処理はバックグラウンドで動かせます。"async": true を付けたコマンドフックは Claude を待たせず、終了すると、その JSON 出力の additionalContext と systemMessage フィールドが次のターンで Claude に届きます。テストランナーの生の出力は届かないので、その JSON を出力するスクリプトで包みます。async フックは何もブロックできず、Claude Code はその timeout を強制せず、claude -p では終了処理の時点でまだ動いているものは kill されます。

遅い Claude Code のフックのコストは?

該当するイベントのたびに、そのフック自身の実行時間がかかります。同期フックは終了するまでアクションを止めるからです。正常に動いていた筆者のガードは、デバッグログ上でツール呼び出し1回あたり25〜28 ms でした。ネットワークサービスやモデルを呼び出すフックははるかにコストが高く、コマンドフックのデフォルトのタイムアウトはほとんどのイベントで10分です。

計算はセッション単位ではなくイベント単位です。matcher のない PreToolUse フックは、エージェントループ内のすべてのツール呼び出しで実行されます。設定ファイルのフックはサブエージェントでも発火するので、サブエージェントも含みます。コストを抑える方法は3つあり、いずれもドキュメントに載っています。

matcher   narrows by tool name, e.g. "Bash" or "Edit|Write"
if        narrows by tool and arguments in permission-rule syntax,
          e.g. "Bash(git *)", so the process never spawns for other calls
async     moves work that can't block anyway off the critical path

prompt フックと agent フックは高価な種類です。prompt フックはデフォルトのタイムアウトが30秒のモデル呼び出し1回なので、Stop に置けば応答の終わりごとに1回の呼び出しになります。agent フックは最大50ターンのツール使用ができるサブエージェントを起動し、デフォルトのタイムアウトは60秒です。遅いフックを見つけるには、デバッグログの各 Hooks: 行の末尾にある所要時間を読みます。

Hooks: PreToolUse:Write [...guard-files.sh] finished with status 2 (26ms)

Claude Code のフックがアクションを通してしまうのはどんなときか?

onFailure を "block" に設定していない限り、フックが失敗したときは常にです。起動できないスクリプト、0 でも 2 でもないコードで終了して有効な判断を出力しないスクリプト、不正な JSON を出力するスクリプト、タイムアウトしたスクリプトは、いずれもブロックしないエラーとして扱われ、ツール呼び出しは通常の権限チェックへと進みます。ドキュメントは、最もよく問題になるケースについて警告しています。

When you set up a policy hook, watch for this notice on its first run,
because a mistyped path in `settings.json` means the hook never runs.

まさにそのケースを試しました。存在しないスクリプトを指す PreToolUse フックです。onFailure なしでは、デバッグログに status 127 が記録され、touch は実行されました。同じフックに "onFailure": "block" を付けると、ログに前述の警告行が加わり、呼び出しは拒否されました。

これは最近変わった点です。10月7日に Claude Code が3つのリリースで塞いだ10の権限バイパスについて書いた時点では、クラッシュやハングしたフックスクリプトについて文書化された挙動はフェイルオープンだけで、回避策はあらゆる失敗を exit 2 に変える bash の EXIT トラップでした。onFailure はその翌日、v2.1.295 で追加されました。command フックと http フックについては、起動できないスクリプト、0 でも 2 でもない exit code、HTTP エラー、タイムアウト、パースできないか検証に失敗した出力を失敗として扱います。

内部で壊れても exit 0 で何も出力せずに終わるスクリプトは捕捉できません。筆者の jq ガードがしたのはまさにこれです。onFailure から見れば、その実行は成功でした。したがって両方が必要です。内部の失敗を 0 以外の終了として表に出すための strict モードか EXIT トラップと、その終了をブロックにつなげる onFailure です。このフィールドは Stop、SubagentStop、TaskCompleted、TeammateIdle のフックと、async フックには効果がありません。

Claude Code の Edit|Write フックは、Bash によるファイル書き込みを止められるか?

止められません。テスト用リポジトリに2つのガードを登録しました。Bash ガードと、protected を含むパスをすべてブロックする Edit|Write ガードです。そのうえで Claude に、1つのファイルを Write ツールで、もう1つを Bash で書くよう頼みました。

Write  protected-a.txt              blocked by guard-files.sh (status 2)
Bash   echo two > protected-b.txt   guard-bash.sh had no rule for it (status 0), file exists

筆者の4月のコミットは、同じ穴を反対側から記録しています。ガードは Bash を監視していて、エージェントは Write と Edit でそれを迂回しました。ファイルを守るガードは、そのファイルに書き込めるすべてのツールをカバーしなければなりません。そして Bash は何にでも書き込めるので、ツール名をキーにしたガードが守るのはルートであって、ファイルではありません。フォーマットについてのドキュメント自身の助言も同じ解決策を指しています。FileChanged は何が書いたかに関係なく、ファイルに反応します。

Claude Code のフックが守ってくれなくなるのは、ほかにどこか?

フックが動く構成の境界です。ツール名の穴のほかに、ドキュメントはさらに4つを挙げています。

if フィルターはベストエフォートです。Bash の入力がどのコマンドを実行するのか Claude Code が判別できない場合はとにかくフックを実行し、これは安全側の挙動です。ただし、それが強制にとって何を意味するかについて、リファレンスははっきり書いています。

Because the if filter is best-effort, use the permission system rather
than a hook to enforce a hard allow or deny.

権限モードはフックの deny を無効にしません。PreToolUse の deny は bypassPermissions を含むすべてのモードで有効です。筆者の Bash ガードは、--dangerously-skip-permissions で起動した実行でもマーカーのコマンドをブロックしました。逆も成り立ちます(ドキュメントによる。ここではテストしていません)。フックの allow は、設定ファイルの deny ルールを覆せません。

フックの後に答えるものが存在しえます。managed settings がなく、Team や Enterprise のログインもないマシンでは、mod が PreToolUse フックのブロックした呼び出しを承認できます。筆者は 2.1.287 でこれを検証し、mod はフックがブロックしたものを承認できるにまとめました。フックのガイドには現在も同じ注記があります。

ヘッドレス実行はリポジトリを信頼します。ドキュメントによれば、対話セッションではワークスペースの信頼ダイアログを承認するまでフックを保留します。claude -p や SDK ではダイアログがなく、リポジトリの .claude/settings.json にコミットされたフックが、一度も開いたことのないフォルダーで実行されます。これについてはクローンしたリポジトリの設定ファイルがまだ実行できるもので詳しく扱いました。自分が書いていないリポジトリでは、--settings '{"disableAllHooks": true}' でその実行1回分だけフックを無効にできます。ただし managed のフックは動き続けます。

そして、事後では遅すぎます。PostToolUse は updatedToolOutput で Claude に見える内容を置き換えられますが、ドキュメントは、書き込まれたファイル、実行されたコマンド、送信されたリクエストはすでに効果を持っていると警告しています。そこに置いた秘匿化フックは、シークレットをモデルからは隠しますが、ネットワークからは隠しません。

最初の Claude Code のフックは何にすべきか?

フォーマッターか通知フックです。初日から役に立ち、壊れても失うのはフォーマットされていないファイル1つです。壊れたガードは、防ぐために書かれた方向へ、しかも静かに失敗します。

それでもガードを書くなら、本番のチェックと同じ扱いをしてください。strict モードか EXIT トラップ、onFailure: "block"、そして依存関係を取り除いた状態でのテストと、不正な入力でのテストを1つずつです。そのうえで、それを正しい名前で呼んでください。ツール名とコマンド文字列をキーにしたフックは誘導です。エージェントが普段書くコマンドを、想定したルート上で捕まえるものです。それを分かったうえでも筆者がガードに残したい部分は、理由の文字列です。以前のガードのメッセージは承認済みのルート(ラッパースクリプト、クレデンシャルをより安全に読む方法)を名指ししていたので、ブロックはただの拒否ではなく、エージェントに次の一手を渡していました。理由は、それを読むモデルに向けて書いてください。コマンドの書き方がどうであれ守られなければならないルールには、コマンドを一切読まない境界が必要です。これは権限バイパスの記事で deny ルールの側から論じたことです。

01 / ディスカッション

コメント欄はありません。議論は X で行っています。

Max Nardit@mnardit

02 / ほかの記事

  1. Claude アプリで auto mode が標準に: チェックが止めるもの、89% の調査が測ったもの

    Anthropic の調査は、その設計については正直です。疲れたテスターに、仕込んだ危険なコマンドを 1 つ見せただけです。誤検知率も実運用での見逃し率も示しておらず、クラウドセッションには戻る先の Manual モードがありません。

  2. Claude Code の料金 2026:同じ作業に Pro、Max、API はいくら請求するか

    プランの料金は定額で、トークン単価も公開されています。そのため比較はただの計算に見えますが、そう単純ではありません。Claude Code は支払い方によって会話のキャッシュの仕方を変えます。そしてサブスクリプションの利用量をドル換算するのに多くの人が使うツールは、実際に使っていたキャッシュで計算するため、乗り換えた後に使うことになるキャッシュの料金は出てきません。

  3. Linux での Claude Code のサンドボックス: srt が遮断するもの、bypassPermissions が開けるもの

    組み込みのサンドボックスはシェルコマンドを囲む本物の OS 境界ですが、囲むのはシェルコマンドだけです。その強さは、セットアップ画面に表示されない 2 つの要素で決まります。権限モードと、コマンドの開始時点で何が存在していたかです。srt が何をブロックし何を通すかを Ubuntu で試した結果、いまも Linux でサンドボックスを壊すバグ、そしてコンテナや VM のほうが合う場面をまとめます。