はじめに / 対象と前提
Claude Code の hooks のうち、ユーザーがプロンプトを送信した瞬間に走る UserPromptSubmit を実装した記録。PreToolUse や Stop と違って「Claude が考え始める前」に割り込めるので、用途は大きく 2 つある。
- プロンプトに追加コンテキストを差し込む(現在のブランチ、チケット番号、今日の日付など)
- プロンプトそのものを送信前に止める(API キーを貼ってしまった、など)
この記事では両方を 1 本の Python スクリプトで実装する。
- 想定読者:Claude Code を日常的に使っていて、hooks を 1 つくらいは書いたことがある人
- 動作確認環境:
- Claude Code 2.1.285
- Python 3.14.3(標準ライブラリのみ)
- git 2.50.1 / macOS 15.7
TL;DR
-
UserPromptSubmitは stdin でpromptとcwdを含む JSON を受け取る。exit 0 で stdout に出した内容が Claude のコンテキストに追加される - 止めたいときは
{"decision": "block", "reason": "..."}を stdout に出して exit 0。reasonはユーザーに表示されるだけで、Claude には渡らない - ハマりどころは「matcher が効かず全プロンプトで発火する」「exit 2 と JSON 出力を混ぜると JSON が捨てられる」「stdout に余計な 1 行が混ざると JSON として解釈されない」の 3 つ
手順 / 動かし方
1. 全体の流れ
2. hook スクリプトを書く
プロジェクト直下に .claude/hooks/prompt_guard.py を置く。
#!/usr/bin/env python3
"""UserPromptSubmit hook: 秘密情報っぽいプロンプトを止め、git 状態を注入する"""
import json
import re
import subprocess
import sys
SECRET_PATTERNS = [
(r"sk-ant-[A-Za-z0-9_\-]{20,}", "Anthropic API キー"),
(r"AKIA[0-9A-Z]{16}", "AWS アクセスキー"),
(r"ghp_[A-Za-z0-9]{36}", "GitHub トークン"),
]
def git(cwd, *args):
try:
r = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=3
)
return r.stdout.strip() if r.returncode == 0 else ""
except (OSError, subprocess.TimeoutExpired):
return ""
def main():
data = json.load(sys.stdin)
prompt = data.get("prompt", "")
cwd = data.get("cwd", ".")
# 1) 秘密情報チェック: 見つけたらプロンプトごと止める
for pattern, label in SECRET_PATTERNS:
if re.search(pattern, prompt):
print(json.dumps({
"decision": "block",
"reason": f"{label}らしき文字列が含まれているので送信を止めた。伏せてから再送すること。",
}, ensure_ascii=False))
return 0
# 2) git 状態を追加コンテキストとして注入
branch = git(cwd, "branch", "--show-current")
if not branch:
return 0 # git 管理外なら何もしない
dirty = [l for l in git(cwd, "status", "--porcelain").splitlines() if l]
context = f"[git] branch={branch} / 未コミットの変更={len(dirty)}件"
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
}
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
ポイントは 3 つ。
-
入力は stdin の JSON。
prompt(送信された文字列)とcwdのほか、session_idやtranscript_pathも入ってくる -
止めるときも exit 0。止める意思は終了コードではなく JSON の
decisionで伝える(理由は後述) - git コマンドには
timeout=3を付ける。この hook は毎プロンプトで同期実行されるので、ここが遅いと体感がそのまま悪くなる
実行権限を付けておく。
chmod +x .claude/hooks/prompt_guard.py
3. settings.json に登録する
.claude/settings.json に追記する。
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/prompt_guard.py",
"timeout": 10
}
]
}
]
}
}
matcher を書いていないのは意図的(ハマりどころ 1 で説明する)。パスは $CLAUDE_PROJECT_DIR 起点にしておくと、Claude がサブディレクトリに cd した後でも hook が見つかる。
4. スクリプト単体で動作確認する
いきなり Claude Code 上で試すと、動かなかったときに「登録ミス」なのか「スクリプトのバグ」なのか切り分けられない。まず stdin に JSON を流して単体で叩くのが早い。
通常のプロンプト:
echo '{"hook_event_name":"UserPromptSubmit","cwd":"/tmp/ups-demo","prompt":"ログインフォームのバリデーションを直して"}' \
| .claude/hooks/prompt_guard.py; echo "exit=$?"
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "[git] branch=feature/login-form / 未コミットの変更=3件"}}
exit=0
キーっぽい文字列を含むプロンプト(値はダミー):
echo '{"hook_event_name":"UserPromptSubmit","cwd":"/tmp/ups-demo","prompt":"このキーで叩いて AKIAXXXXXXXXXXXXXXXX"}' \
| .claude/hooks/prompt_guard.py; echo "exit=$?"
{"decision": "block", "reason": "AWS アクセスキーらしき文字列が含まれているので送信を止めた。伏せてから再送すること。"}
exit=0
git 管理外のディレクトリ(cwd を /tmp にした場合)は何も出力せず exit=0 で抜けることも確認した。「何も出さずに exit 0」は「何もしない」の意味になるので、対象外のケースはこれで良い。
5. Claude Code 上で確認する
単体で通ったら Claude Code を起動し直してから確認する。確認手順は次のとおり。
-
/hooksを開き、UserPromptSubmitに登録したコマンドが表示されているか見る - 「今いるブランチは?コマンドは実行せずに答えて」と送る。git コマンドを叩かずにブランチ名が返ってくれば、注入したコンテキストが読まれている
- ダミーのキー文字列を含むプロンプトを送り、
reasonの文言が表示されて Claude が応答しないことを見る
claude --debug で起動すると、hook の実行と出力がログに出るので、2 で期待どおり動かないときはそちらを見る。
ハマりどころ
1. matcher を書いても効かない — 全プロンプトで発火する
PreToolUse の感覚で、こう書きたくなる。
{ "matcher": "deploy|本番", "hooks": [ ... ] }
「deploy を含むプロンプトのときだけ走る」ように見えるが、UserPromptSubmit は matcher をサポートしていない。matcher はツール名などイベントごとに決まった対象に対するフィルタであって、プロンプト本文に対する検索ではない。書いても黙って無視され、すべてのプロンプトで発火する。
-
原因:matcher の対象がイベントごとに決まっていて、
UserPromptSubmitとStopには対象が無い -
回避策:絞り込みはスクリプトの中で
promptを見てやる。対象外なら何も出さずに exit 0
if not re.search(r"deploy|本番", prompt):
return 0 # 対象外。何も出さない = 何もしない
全プロンプトで走る以上、スクリプトの先頭で早期リターンして軽く保つのが大事になる。
2. exit 2 で止めると JSON が捨てられる
hook でブロックする方法は 2 系統ある。
| 方法 | 終了コード | メッセージの出し先 |
|---|---|---|
| シンプル | exit 2 | stderr に書く |
| JSON | exit 0 |
stdout に {"decision":"block","reason":...}
|
自分は最初、この 2 つを混ぜてこう書いていた。
print(json.dumps({"decision": "block", "reason": "..."}))
sys.exit(2) # 止めたいから 2 にしたつもり
これだとプロンプトは止まるものの、reason が表示されない。stdout の JSON が解釈されるのは exit 0 のときだけで、exit 2 のときは stderr しか使われないため。stderr に何も書いていなければ、ユーザーには理由が伝わらないまま入力が消える。
- 原因:exit 2 は「stderr を使うブロッキングエラー」、JSON 出力は「exit 0 のときだけパースされる」という別系統の仕組み
-
回避策:どちらか片方に揃える。
additionalContextと同じスクリプトで扱うなら JSON 方式(exit 0)に統一するのが楽
もう 1 点、UserPromptSubmit のブロックは PreToolUse と挙動が違う。PreToolUse の exit 2 は stderr が Claude に渡って「別の方法を試す」きっかけになるが、UserPromptSubmit で止めた場合はプロンプトごと破棄され、理由はユーザーにしか見えない。Claude はそのターンを処理しないので、「理由を Claude に伝えて言い換えさせる」ことはできない。入力欄からも消えるので、reason には「何を直して再送すればいいか」まで書いておくと親切。
3. stdout に 1 行混ざるだけで JSON として解釈されなくなる
デバッグのつもりで print("hook start") を足したら、注入内容が崩れた。
hook start
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "..."}}
UserPromptSubmit は「exit 0 の stdout がプレーンテキストでもコンテキストに追加される」という仕様になっている。そのため stdout 全体が JSON として読めないと、エラーにはならず、デバッグ行と JSON の文字列がまるごとテキストとして Claude に渡る。止めるつもりの decision: block も同じ理由でただの文字列になり、プロンプトは素通りする。エラーが出ないので気づきにくい。
- 原因:stdout 全体が 1 つの JSON でない場合はプレーンテキスト扱いになる
-
回避策:
- デバッグ出力は必ず stderr に出す(
print(..., file=sys.stderr)) - シェルスクリプトから別コマンドを呼ぶ場合は、そのコマンドの stdout を
>/dev/nullか>&2に逃がす - 単体テストで出力を
python3 -m json.toolに通し、JSON として成立しているか機械的に確認する
- デバッグ出力は必ず stderr に出す(
echo '{"cwd":".","prompt":"test"}' | .claude/hooks/prompt_guard.py | python3 -m json.tool
逆に言えば、止める必要がなく注入だけしたい用途なら、JSON を組まずに print("[git] branch=...") だけで済む。
背景・補足
-
注入しすぎない:
additionalContextは毎ターン積み上がる。git diff全文のような大きなものを毎回入れると、コンテキストを自分で圧迫することになる。1〜2 行の要約に留め、詳細が必要なら Claude 自身にコマンドを叩かせる方が良い -
設定変更は起動中のセッションに即反映されない:settings.json を外部エディタで書き換えた場合、
/hooksで内容を確認するか、セッションを起動し直してから試す - 正規表現での秘密情報検出は保険:パターンに無い形式のキーは通る。「貼らない」が基本で、hook は最後の網として考える
- SessionStart との使い分け:セッション開始時に 1 回入れれば済む情報(プロジェクトの前提など)は SessionStart、ターンごとに変わる情報(ブランチ、未コミット件数、時刻)は UserPromptSubmit が向いている
まとめ
-
UserPromptSubmitは「Claude が考える前」に割り込める hook。exit 0 の stdout がコンテキストに追加される - 止めるときは
{"decision":"block","reason":...}を出して exit 0。exit 2 と混ぜるとreasonが消える - matcher は効かない。絞り込みはスクリプト内で
promptを見て早期リターン - stdout は「JSON だけ」に保つ。デバッグ出力は stderr へ
- Claude Code に載せる前に、stdin に JSON を流して単体で叩くと切り分けが速い