1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の UserPromptSubmit hook でプロンプト送信時に git 状態を自動注入し、API キーの貼り付けをブロックする実装手順 — matcher は効かない・exit 2 の stderr は Claude に届かない・JSON は exit 0 のときだけ解釈、3つのハマりどころ【2026】

1
Posted at

はじめに / 対象と前提

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 を起動し直してから確認する。確認手順は次のとおり。

  1. /hooks を開き、UserPromptSubmit に登録したコマンドが表示されているか見る
  2. 「今いるブランチは?コマンドは実行せずに答えて」と送る。git コマンドを叩かずにブランチ名が返ってくれば、注入したコンテキストが読まれている
  3. ダミーのキー文字列を含むプロンプトを送り、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 として成立しているか機械的に確認する
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 を流して単体で叩くと切り分けが速い
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?