UserPromptSubmit hookでプロンプト送信前に介入する
Claude Codeがプロンプトを処理する前に発火するUserPromptSubmit hookで、コンテキストの注入とプロンプトのブロックを実装する手順を解説します。
UserPromptSubmit hookとは
UserPromptSubmitは、ユーザーがプロンプトを送信した直後、Claudeがそれを処理する前に発火するhookイベントです。プロンプトや会話の内容に応じてコンテキストを追加したり、プロンプトを検証したり、特定の種類のプロンプトをブロックしたりできます。
このhookには重要な制約があります。command・http・mcp_toolタイプの既定タイムアウトは30秒で、多くの他のイベントの既定である600秒よりかなり短めです。理由は単純で、このhookはすべてのプロンプトの前で走り、完了するまでモデル処理をブロックするからです。処理に時間がかかるhookを書くと、そのままセッション全体が止まります。時間が必要な場合は、hookエントリのtimeoutフィールドで個別に延長します。
もう一つの制約はmatcherに対応していないことです。UserPromptSubmitは常にすべてのプロンプト送信で発火し、特定のツールやエージェントに絞り込む仕組みがありません。設定にmatcherフィールドを書いても、対応していないイベントでは黙って無視されます。
何ができるか — コンテキスト注入とブロックの2軸
UserPromptSubmitが受け取る入力は、session_id・prompt_id・transcript_path・cwd・scratchpad_dir・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"
}hookが返せる決定は次の5つです。exit 0でJSON出力を行うか、exit 2でブロックするかで扱いが変わります。
| フィールド | 説明 |
|---|---|
decision | 説明"block"でプロンプト処理を止め、会話から消去する。省略すれば処理が進む |
reason | 説明decisionが"block"のときにユーザーへ表示される文言。コンテキストには追加されない |
additionalContext | 説明送信されたプロンプトに添えてClaudeのコンテキストへ追加する文字列 |
sessionTitle | 説明セッションタイトルを設定する。プロンプト内容から自動命名する用途 |
suppressOriginalPrompt | 説明decisionが"block"のときtrueにすると、ユーザーへの表示メッセージから元のプロンプト本文を省く |
コンテキストを追加する経路は2つあります。stdoutをそのままプレーンテキストとして出す方法と、additionalContextをJSONで返す方法です。どちらもチャット上には表示されず、hook名で始まるsystem reminderとして会話に差し込まれます。届いているかを確認したいときは、可視のトランスクリプトではなくデバッグログを見る必要があります。
設定方法 — settings.jsonにhookを書く
hookの定義場所によってスコープが変わります。個人設定の~/.claude/settings.jsonは自分の全プロジェクトに、.claude/settings.jsonはそのプロジェクトのコミット済み設定として共有チームに、.claude/settings.local.jsonはgitignoreされたローカル専用として効きます。管理ポリシー設定や、プラグインのhooks/hooks.json、SkillやSubagentのfrontmatterに書く方法もあります。
UserPromptSubmitはmatcherに対応していないため、設定はシンプルです。
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/path/to/prompt-guard.sh"
}
]
}
]
}
}hookは設定ファイルだけでなく、SubagentがツールでUserPromptSubmit相当のイベントを扱う場合を除き、通常はメインの会話フローに対して動きます。同じhandlerを複数の設定ファイルに重複定義しても1回だけ実行され、プラグインやSkillが独自に持つ同一handlerはそれとは別扱いで実行されます。
コンテキストを注入する実装例
現在のGitブランチや、その日のCI状況のようにセッション外部の状態をClaudeに知らせたいとき、additionalContextが使えます。
#!/bin/bash
input=$(cat)
prompt=$(jq -r '.prompt' <<<"$input")
branch=$(git branch --show-current 2>/dev/null || echo "unknown")
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "現在のブランチ: ${branch}"
}
}
EOF
exit 0このJSONをexit 0で返すと、Claudeは次のモデルリクエストでこの内容を読みますが、チャット上のメッセージとしては表示されません。additionalContextの値が10,000字を超える場合は、Claude Codeがその内容をセッションディレクトリのファイルへ書き出し、短いプレビューとファイルパスだけをClaudeへ渡します。長大なログやドキュメント全文をそのまま流し込む設計にはしない方がよいということです。
プロンプトをブロックする実装例
ブロックには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 0exit 2で終了すると、JSONで何を返していてもUserPromptSubmitではプロンプト処理がブロックされ、コンテキストから消去されます。ブロックメッセージは、JSONのreasonがあればその文言、なければstderrのテキストが使われます。JSON出力がスキーマ検証に失敗していても、exit 2である限りブロック自体は成立し、stderrがブロック理由として使われます(v2.1.214より前はこの組み合わせを非ブロッキングエラーとして扱い、処理が進んでしまっていました)。
タイムアウトが絡む挙動には注意が必要です。async: trueで動かすコマンドhookを除き、UserPromptSubmitのcommand・http・mcp_toolフックがタイムアウトに達すると、そのhookの出力(additionalContextを含む)はまるごと破棄されます。プロンプトはコンテキストが乗らないままClaudeへ届き、処理自体は止まりません。トランスクリプトにはhook名とタイムアウト値、出力が破棄された旨の通知が表示されます。つまりタイムアウトは「安全側に倒してブロックする」のではなく、「そのプロンプトに関しては何も起きなかったことにする」動きをします。ポリシーゲートとして使うつもりのhookがこの挙動になると、検知漏れに直結します。
Agent SDKのコールバックhookは挙動が異なります。UserPromptSubmitのコールバックhookがタイムアウトに達すると、hook名とタイムアウト値を含むメッセージでプロンプトそのものをブロックします。セッションは継続します。コールバックはフェイルオープンしてはいけないポリシーゲートとして扱われているためです。v2.1.208より前は、同じ状況でターンが実行エラーとして終了していました。
UserPromptExpansionとの違い
/で始まるスラッシュコマンドやMCPサーバーのpromptがプロンプトへ展開される経路は、UserPromptSubmitではなくUserPromptExpansionが担当します。両者は発火するタイミングが異なるため、目的に応じて使い分けが必要です。
| 観点 | UserPromptSubmit | UserPromptExpansion |
|---|---|---|
| 発火タイミング | UserPromptSubmitユーザーが送信した生のプロンプトすべて | UserPromptExpansionスラッシュコマンド・MCP promptが展開される時 |
| matcher | UserPromptSubmit対応なし(常に発火) | UserPromptExpansioncommand_nameでフィルタ可能 |
| 主な入力 | UserPromptSubmitprompt | UserPromptExpansionexpansion_type・command_name・command_args・command_source・prompt |
| PreToolUseとの関係 | UserPromptSubmit関係なし | UserPromptExpansionSkillツールに対するPreToolUseが拾わない、/skillname直接入力の経路を拾う |
| 用途の例 | UserPromptSubmit危険な単語を含むプロンプト全般の検査 | UserPromptExpansion特定のスラッシュコマンドを承認ファイルの有無でブロック |
PreToolUseがSkillツールにマッチするhookは、Claudeがそのツールを呼び出したときだけ発火します。ユーザーが/skillnameと直接入力する経路はこのPreToolUseを経由しないため、UserPromptExpansionでしか捕まえられません。特定のスラッシュコマンドだけを制御したい場合は、UserPromptSubmitではなくUserPromptExpansionを選びます。
バージョンごとの挙動変化
UserPromptSubmitまわりの挙動は複数のバージョンで変わっています。古いバージョンで動かしている場合は挙動が異なる可能性があります。
| バージョン | 変わった内容 |
|---|---|
| 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フィールドが追加 |
よくあるつまずき
matcherを書いても効かない。 UserPromptSubmitはイベント全体としてmatcherに対応していません。設定にmatcherフィールドを足しても黙って無視され、hookは毎回のプロンプト送信で発火し続けます。特定の条件だけに絞りたい場合は、hookスクリプト側でpromptの中身を見て判定するしかありません。
タイムアウトが30秒に短縮されていることを見落とす。他のイベントの感覚で長めの処理(外部APIの呼び出しやファイルの走査)を書くと、既定の30秒であっさりタイムアウトします。しかもタイムアウト時はエラーとして目立つのではなく、出力が静かに破棄されてプロンプトがそのまま進むだけです。重い処理をさせたいならtimeoutフィールドを明示的に延長するか、async: trueでバックグラウンド実行に回します。
ifフィールドで絞り込めると思い込む。hook handlerのifフィールドはBashコマンドやファイルパスに対するパーミッションルール構文で絞り込む仕組みですが、評価されるのはPreToolUse・PostToolUse・PostToolUseFailure・PermissionRequest・PermissionDeniedというツールイベントだけです。UserPromptSubmitのhandlerにifを設定しても、そのhookは一度も実行されません。ツールの呼び出しではなくプロンプトの中身で絞り込みたい場合は、ifではなくスクリプト内部での文字列判定に頼ることになります。
exit 1で止まると思い込む。ブロックが成立するのはexit 2だけです。exit 1やそれ以外のコードは、有効なJSON決定が無ければ「決定なし」として扱われ、プロンプトはそのままClaudeへ渡ります。
additionalContextが表示されないと不安になる。 additionalContextも素のstdoutも、チャット上のメッセージとしては表示されない設計です。system reminderとして会話に差し込まれるだけなので、届いているかどうかはデバッグログで確認します。
HTTPフックの許可リストを設定し忘れる。 type: "http"のhookは、allowedHttpHookUrlsの許可リストに含まれるURLに対してのみ実行されます。ローカルのcommand hookから移行する際に見落としやすいポイントです。HTTPエンドポイントとして受ける具体的な実装はClaude CodeのHooksをHTTPエンドポイントで受けるで扱っています。
まとめ
UserPromptSubmitはプロンプトが処理される前の唯一の割り込み地点で、コンテキストの注入とブロックの両方に使えます。ただし既定タイムアウトが30秒と短く、matcherに対応せず、タイムアウト時は出力が静かに破棄されるという3つの制約が、他の多くのhookイベントとの違いです。ポリシーゲートとして使うなら、タイムアウトを想定した実装と、exit 2による明示的なブロックの組み合わせが前提になります。
セッション開始時に一度だけ走るSetup/InstructionsLoadedのようなhookとの実装の違いはSetup/InstructionsLoadedフックの実務設定で扱っています。プロンプトそのものではなく推論の入口を検査したい場合は、Inference hooksとはという別の仕組みも検討してください。