UserPromptSubmit hookの使い方 — 注入・ブロック・hook errorの対処
UserPromptSubmit hookはプロンプト送信後、Claudeが処理する前に動きます。コンテキストの注入、exit 2でのブロック、hook errorの読み方を手順で示します。
UserPromptSubmit hookとは
UserPromptSubmit hookは、プロンプトを送った直後、Claudeが読む前に自作スクリプトを走らせる仕組みです。additionalContextを返せば情報を足せ、exit 2で終われば送信を止められます。
UserPromptSubmitは、ユーザーがプロンプトを送信した直後、Claudeがそれを処理する前に発火するhookイベントです。プロンプトや会話の内容に応じてコンテキストを足したり、プロンプトを検証したり、特定の種類のプロンプトを止めたりできます。
ただし、このイベントは他のhookイベントと同じ感覚で書くと3か所でつまずきます。いずれも公式の仕様どおりの挙動ですが、書いた本人は気づきにくい性質のものです。
他のイベントと違う3点
既定のタイムアウトが30秒
command・http・mcp_toolの既定は、多くのイベントの600秒に対して30秒です。毎回のプロンプトの前で走り、終わるまでモデル処理を止めるためです。長くしたいときはhookエントリのtimeoutで個別に延ばします。matcherがない
すべてのプロンプト送信で発火します。
matcherを書いても絞り込みにはならず、条件分岐はスクリプトの中でpromptを見て行います。ifを書くと一度も実行されない
ifが評価されるのはツール系のイベントだけです。UserPromptSubmitのhandlerにifを付けると、そのhookは一度も動きません。
3つ目は特に気づきにくい点です。チャットには何も表示されないので、「効いていない」ことにしか気づけません。
入力に入ってくる情報
hookのスクリプトはstdinからJSONを受け取ります。共通フィールドはsession_id・transcript_path・cwd・permission_mode・hook_event_nameなどです。このイベント固有なのはpromptで、ユーザーが入力したテキストが入ります。
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "Write a function to calculate the factorial of a number"
}プロンプトを解析するスクリプトでは、貼り付けテキストの扱いに注意が要ります。[Pasted text #N]というプレースホルダーに折りたたまれた貼り付けは、promptの中では展開された状態で届きます。貼り付けにマークが付くセッションでは、展開された内容が<pasted_content id="…">の行と</pasted_content id="…">の行ではさまれます。正規表現で先頭や末尾に一致させる判定は、この行の分だけずれます。feature flagを取得しないセッションでは、マークは付きません。
セッションにカスタムタイトルがあればsession_titleも入ります。prompt_idはv2.1.196から、scratchpad_dirはv2.1.257から入るフィールドで、最初のユーザー入力が来るまでprompt_idはありません。
設定ファイルに書く
hookの定義場所でスコープが変わります。~/.claude/settings.jsonは自分の全プロジェクトに効きます。.claude/settings.jsonはコミットしてチームと共有し、.claude/settings.local.jsonはgitignoreされるローカル専用です。管理ポリシー、プラグインのhooks/hooks.json、SkillやSubagentのfrontmatterにも書けます。同じhandlerを複数の設定ファイルに定義しても実行は1回で、プラグインやSkillが持つ同一handlerは別扱いで実行されます。
UserPromptSubmitはmatcherを持たないので、設定はコマンドを指すだけで済みます。
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/path/to/prompt-guard.sh"
}
]
}
]
}
}ブランチ名を毎回Claudeに伝える
セッションの外にある状態をClaudeに知らせたいときはadditionalContextを使います。現在のGitブランチが典型例です。
#!/bin/bash
input=$(cat)
branch=$(git branch --show-current 2>/dev/null || echo "unknown")
jq -n --arg b "$branch" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: ("現在のブランチ: " + $b)
}
}'
exit 0v2.1.287の環境で、feature-xブランチのgitリポジトリを作り、手元でスクリプトにダミーのJSONを渡してみました。
echo '{"prompt":"x"}' | ./ctx.sh; echo "exit=$?"出力は次のとおりです。
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "現在のブランチ: feature-x"
}
}
exit=0JSONを組み立てるときは、ヒアドキュメントに変数を直接埋めるよりjq -n --argのほうが安全です。ブランチ名に引用符やバックスラッシュが入ると、素の文字列連結ではJSONが壊れるからです。壊れたJSONは注入されません(詳しくは「よくあるつまずき」)。
注入された内容はチャットに表示されません。プレーンなstdoutもadditionalContextも、hook名で始まるsystem reminderとしてプロンプトに添えられ、Claudeには読まれます。届いたかどうかは、チャットではなくデバッグログで確かめます。
書き方にはコツがあります。「現在のデプロイ先は本番です」「このリポジトリはbun testを使います」のように事実の文にすると、プロジェクト情報として読まれます。システムへの命令のような文面にすると、プロンプトインジェクション対策が働いて、Claudeが内容をユーザーに見せてしまうことがあります。
長さにも上限があります。additionalContext・systemMessage・initialUserMessageとプレーンなstdoutは、それぞれ10,000字までです。超えた分はセッションディレクトリのファイルに保存され、Claudeには先頭2,000字までのプレビューとファイルパスだけが渡ります。ファイルを読むかどうかはClaude任せなので、必ず見せたい内容は上限内に収めます。この上限を引き上げる設定はありません。
変わらない指示まで毎回注入する必要はありません。常に同じ規約なら、スクリプトを走らせずに読み込まれるCLAUDE.mdに置くほうが素直です。
再開時の挙動も押さえておきます。--continueや--resumeで再開すると、過去のターンについてはhookを走らせ直さず、保存済みの注入テキストがそのまま再生されます。タイムスタンプやコミットSHAのような値は、再開後は古いままです。
出力側のsessionTitleを使うと、プロンプトの内容からセッションのタイトルを自動で付けられます。入力のsession_titleにカスタムタイトルが入っていれば、それを先に確認することで、既存のタイトルを上書きせずに済みます。
危険なプロンプトをブロックする
止める方法は2つあります。JSONでdecision: "block"を返すか、exit 2で終了するかです。後者の例を見ます。
#!/bin/bash
input=$(cat)
prompt=$(jq -r '.prompt' <<<"$input")
if [[ "$prompt" == *"rm -rf /"* ]]; then
echo "危険な操作を示唆するプロンプトです" >&2
exit 2
fi
exit 0同じ環境で、2種類のプロンプトを渡してみました。
echo '{"prompt":"clean up with rm -rf /tmp/x"}' | ./prompt-guard.sh; echo "exit=$?"
echo '{"prompt":"hello"}' | ./prompt-guard.sh; echo "exit=$?"危険な操作を示唆するプロンプトです
exit=2
exit=01行目に注目してください。rm -rf /tmp/xは特定のディレクトリを消すだけの操作ですが、部分一致の*"rm -rf /"*に引っかかって止まりました。文字列の部分一致によるフィルタは、安全な操作まで巻き込みます。「/」の直後が空白か行末の場合だけ止めるといった、パターンの精度を上げる工夫が必要です。
exit 2で終了すると、JSONに何を書いていてもプロンプトは止まり、Claudeには届きません。止めたときにユーザーへ見える文言は、JSONのreasonがあればそれ、なければstderrのテキストです。reasonはコンテキストには追加されません。exit 2でJSONがスキーマ検証に失敗していても、ブロック自体は成立してstderrが理由になります。この組み合わせはv2.1.214より前は非ブロッキングエラーとして扱われ、処理が進んでいました。
止めたプロンプトの本文はどこに残るか
ブロックされたプロンプトはClaudeに届きませんが、本文がどこからも消えるわけではありません。既定ではユーザーへのブロックメッセージの末尾にOriginal prompt:に続けて送信テキストが付き、そのメッセージはセッションのトランスクリプトファイルにも書き込まれます。
パスワードや鍵を貼ってしまったプロンプトを止めるhookでは、これが問題になります。hookSpecificOutputの中で"suppressOriginalPrompt": trueを出力すると、メッセージから本文が省かれます。decision: "block"でもexit 2でも有効です。ただしexit 2で何もJSONを出さないhookは、常に本文付きのメッセージになります。
#!/bin/bash
input=$(cat)
prompt=$(jq -r '.prompt' <<<"$input")
if [[ "$prompt" == *"AKIA"* ]]; then
jq -n '{
decision: "block",
reason: "アクセスキーらしき文字列が含まれています",
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
suppressOriginalPrompt: true
}
}'
fi
exit 0例のAKIAの部分一致は説明用で、実際はAKIA[0-9A-Z]{16}のような正規表現にします。
この設定が変えるのはブロックメッセージだけです。送信テキストはトランスクリプトやプロンプト履歴といったローカルファイルに残りうるので、ブロックするhookは秘密をディスクに残さない手段にはなりません。
タイムアウトしたプロンプトはどうなるか
毎回のプロンプトで走るhookにとって、時間切れの挙動は設計の前提になります。実行方式で結果が正反対になる点が要注意です。
タイムアウトに達したときの挙動
command・http・mcp_toolのhook
hookは打ち切られ、additionalContextを含む出力がすべて捨てられます。プロンプトはコンテキストなしでClaudeに届き、処理は止まりません。トランスクリプトには、hook名・タイムアウト値・出力を破棄した旨の通知が出ます。async: trueのコマンドhookは例外です。
Agent SDKのコールバックhook
hook名とタイムアウト値を含むメッセージでプロンプトそのものが止まり、セッションは続きます。ポリシーゲートとして働いているコールバックはフェイルオープンしてはいけない、という考え方です。v2.1.208より前は、ターンが実行エラーで終わっていました。
つまり、設定ファイルに書いたcommand hookをポリシーゲートにすると、時間切れのときだけ検査が無効になります。外部APIへの問い合わせや大きなファイルの走査を挟むなら、timeoutを明示して延ばすか、重い判定をhookの外に出して、スクリプトは結果のキャッシュを読むだけにする構成が考えられます。ログ記録や通知のように結果をそのプロンプトに使わない処理だけは、async: trueに回せます。出力は次のターンに届き、ブロックはできません。
UserPromptExpansionとの使い分け
/で始まるスラッシュコマンドやMCPサーバーのpromptが展開される経路は、UserPromptExpansionが担当します。
| 観点 | UserPromptSubmit | UserPromptExpansion |
|---|---|---|
| 発火タイミング | UserPromptSubmit送信された生のプロンプトすべて | UserPromptExpansionスラッシュコマンド・MCP promptが展開される時 |
| matcher | UserPromptSubmitなし(常に発火) | UserPromptExpansioncommand_nameでフィルタ可能 |
| 主な入力 | UserPromptSubmitprompt | UserPromptExpansionexpansion_type・command_name・command_args・command_source・prompt |
| 用途の例 | UserPromptSubmit秘密情報や危険な文字列の検査 | UserPromptExpansion承認ファイルが無いかぎり/deployを止める |
PreToolUseでSkillツールにマッチさせたhookは、Claudeがそのツールを呼んだときしか発火しません。ユーザーが/skillnameと直接入力する経路はそこを通らないので、止めたいのが特定のスラッシュコマンドならUserPromptExpansionを使います。
動いているかを確かめる
注入した内容はチャットに出ないため、動作確認には手順が要ります。
hookの動作確認
- 1
スクリプトを単体で動かす
上で見たように、ダミーのJSONをパイプで渡し、出力とexitコードを見ます。Claudeを起動する前に、JSONの崩れやパターンの誤爆を見つけられます。
- 2
ログファイルを指定して起動する
claude --debug-file <path>でログの出力先を固定できます。--debugだけではターミナルには何も出ず、ログは~/.claude/debug/<session-id>.txtに書かれます。--debug='mcp,startup'のようにカテゴリで絞る指定もできます。絞り込みが効くのは=で結ぶ形だけで、空白で区切ると絞り込みなしのデバッグモードになります。 - 3
注入の到達をログで確認する
プロンプトを1回送り、ログでhook名を探します。hookが失敗していれば、トランスクリプトに
Failed with non-blocking status code:で始まる通知が出ます。
「hookが原因か切り分けたい」ときは、claude --safe-modeでカスタマイズをすべて無効にして起動できます。認証・モデル選択・組み込みツール・権限は通常どおり動きますが、管理ポリシーで設定されたhookは止まりません。--bareはhookだけでなくCLAUDE.md・MCP・Skillsも読み込みません。サブスクリプションのログインも使えずANTHROPIC_API_KEYが必要なので、切り分けには--safe-modeのほうが向きます。--settings '{"disableAllHooks": true}'で全hookを止める方法もあります。
よくあるつまずき
stdoutがJSONになりきっていない
出力が{で始まるのに}で終わらなければ、プレーンテキストとして注入されます。{と}で挟まれているのにパースできないときは非ブロッキングエラーになり、v2.1.248以降はテキストとしても注入されません。デバッグ用のechoをJSONの前に残すと、出力が{で始まらないので、JSONとしては読まれません。
「hook error」と表示されたとき
トランスクリプトにUserPromptSubmit hook errorのような通知が出ても、それだけでプロンプトが止まったとは限りません。Failed with non-blocking status code:が付いていれば、終了コードが0でも2でもなかったという意味で、処理は進んでいます。通知に添えられたstderrの1行目が原因の手がかりです。全文はデバッグログで確かめます。スクリプトのパス違いやJSONの崩れも、この通知で現れます。
HTTPフックの許可リストを設定し忘れる
type: "http"のhookは、設定でallowedHttpHookUrlsが定義されているとき、許可リストに合致するURLにしか実行されません。HTTPエンドポイントで受ける実装はClaude CodeのHooksをHTTPエンドポイントで受けるで扱っています。
バージョンごとの挙動変化
古いバージョンで動かしている環境では、ここまでの挙動が違うことがあります。
| バージョン | 変わった内容 |
|---|---|
| v2.1.196 | 変わった内容入力にprompt_idフィールドが追加 |
| v2.1.208 | 変わった内容Agent SDKコールバックhookのタイムアウトが、ターンの実行エラー終了からプロンプトのブロックへ変更 |
| v2.1.214 | 変わった内容exit 2でJSONがスキーマ検証に失敗しても、ブロックが成立するよう変更 |
| v2.1.248 | 変わった内容stdoutのJSONパースに失敗した場合、プレーンテキストとしての注入も行われないよう変更 |
| v2.1.257 | 変わった内容入力にscratchpad_dirフィールドが追加 |
まとめ
注入にもブロックにも使えます。ただ、検査ゲートや秘密の遮断までこのhook単独に任せると、時間切れで素通りし、止めた後も本文がローカルに残ります。別の層と組み合わせる前提で設計します。
セッション開始時に一度だけ走るhookとの違いはSetup/InstructionsLoadedフックの実務設定にあります。推論の入口を検査する別の仕組みはInference hooksとはで扱っています。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。