
Claude Codeにうっかり貼ってしまった秘密情報・個人情報を、送信前に止めるフックを自作してみた
はじめに
Claude Codeでの開発中、エラーログやAPIレスポンス、DBのレコードなどをそのまま貼り付けて相談したくなりますよね。しかし、そこにアクセストークンや顧客の個人情報が含まれていた場合、一度送信してしまうと取り消すことはできません。
そこで、入力・貼り付けたプロンプトをローカルで検査し、機密情報を検出したら送信をブロックするフックを作成しました。ブロック時には該当箇所をマスクしたテキストがクリップボードにコピーされるため、内容を確認してすぐに貼り直して送信できます。
前提:この仕組みでカバーできる範囲
- できること: 送信前にローカルでルール検査を行い、合致したプロンプトの送信を止める。
- できないこと: あらゆる機密情報の100%検出。また、元の入力をバックグラウンドで自動置換して送信すること。
- 注意点: スクリプトの起動失敗やタイムアウト時はプロンプトがそのまま通過します(完全な流出防止策ではありません)。
以前の記事「AutoModeでも安心できるセキュリティ設定をしてみた話」ではファイル読み取り等の対策を紹介しましたが、今回はユーザー自身の直接入力ミスに対象を絞った対策です。
なぜ送信前に止めるのか
sandboxやpermissionsは、主にClaude側が実行するツール操作(コマンド実行やファイル読み書き)を制限する仕組みです。ユーザーが入力欄へ直接貼り付けた文字列まではマスクしてくれません。
利用プランや学習設定にかかわらず、誤って送信した秘密情報はトークンの再発行が必要になりますし、個人情報であれば社内規程によってはインシデント報告が必要になります。また、Claudeがその値をコードやコミットメッセージに引用し、リポジトリ内に拡散してしまうリスクもあります。
送信後の対応に追われるのではなく、入力の段階で「もう一段のセーフティネット」を設けるために作成しました。
UserPromptSubmit でできること・できないこと
今回利用したのは、プロンプトがClaudeに渡される直前に実行される UserPromptSubmit フックです。標準入力からJSON(prompt フィールド)を受け取り、送信ごとに呼び出されます(デフォルトタイムアウト30秒)。
| 項目 | できること | できないこと |
|---|---|---|
| 本文の扱い | 送信前に本文を取得・検査できる | 本文を書き換えて送信することはできない(additionalContext の追記のみ可能) |
| 制御 | exit 2 や decision: "block" で送信を止められる |
exit 1 等のエラー終了やタイムアウト時は、ブロックされず送信されてしまう |
| 通知 | ユーザー画面にのみブロック理由を表示できる | — |
フック側でプロンプトを自動置換して送信することは仕様上できません。そのため、「検出したらいったん送信を止め、ユーザーがマスク結果を確認して手動で再送信する」 という設計にしています。
参考:Hooksリファレンス
動作フロー
- ユーザーがテキストを入力して送信。
- ローカルのPythonスクリプトが呼び出され、
promptの内容を検査。 - 機密情報を検出した場合は送信をブロックし、該当箇所を
****に置き換えたテキストをクリップボードにコピー。 - ユーザーがマスク結果を目視確認したうえで貼り付け、再送信。
人名や住所など、正規表現ルールで拾いきれなかった情報が残っている可能性があるため、再送信前の目視確認を前提とした運用にしています。
検出対象とアプローチ
Pythonの標準ライブラリのみで実装し、パターン化しやすい秘密情報と代表的な個人情報に対象を絞っています。
| アプローチ | 主な対象 | 判定の考え方 |
|---|---|---|
| 固定パターン | AWSアクセスキー、GitHubトークン、JWTなど | 接頭辞、使用文字種、文字列長をチェック |
| 前後の文脈 | Bearerトークン、URL内のパスワードなど |
認証を示すキーワードや前後の区切り文字から抽出 |
| キーと値のペア | APP_CLIENT_SECRET=...、password: ...など |
設定ファイル等でよく使われるキー名と値の組み合わせをチェック |
| 形式+バリデーション | クレジットカード番号、マイナンバーなど | 桁数に加え、チェックデジットの計算アルゴリズムで検証 |
| 形式+除外条件 | メールアドレス、日本の電話番号 | 形式の一致を確認しつつ、テスト用ドメイン(example.com等)を除外 |
※人名や住所、独自仕様のトークン、キー名のないランダムな文字列、画像内の文字などは網羅的に検出できません。
設計の工夫
- 判定にLLMを使わない
保護対象の機密情報を判定のために外部モデルへ送信しては本末転倒なため、完全ローカルのPythonスクリプト(command型フック)で処理しています。 - ブロック画面に機密情報を出さない(
suppressOriginalPrompt)
ブロック時のメッセージには、検出した「種別」と「件数」のみを表示します。また、デフォルトではブロック画面に元の入力がOriginal prompt:として露出してしまうため、JSON出力でhookSpecificOutput.suppressOriginalPrompt: trueを指定してターミナルへの表示を抑制しています(スラッシュコマンド(UserPromptExpansion)で止めた場合は対象外です。また、消えるのはブロック時のメッセージだけで、手元の会話の記録や入力履歴には元の文章が残ります)。 - スクリプト内のエラーはブロック側に倒す
例外発生時に誤って送信を通過させないよう、スクリプト内部のエラーはすべてキャッチしてexit 2(ブロック)で終了させます。 - マスク処理の二重検知防止
送り直したマスク版が再び止まらないよう、マスク後の****を検出対象から除外しています。また、複数ルールが同じ箇所に重複ヒットした場合のズレを防ぐため、範囲をマージしてから一括マスクしています。 - 誤検知用のスキップタグ
安全なダミーデータが引っかかった場合に備え、プロンプトの文頭に[allow-pii]や[allow-secret]を記述すると検査を意図的にスキップできる仕組みを取り入れました(文中のタグは無効です)。
実際にブロックされたときの画面です。検出した種類と件数だけが表示され、元の文章は表示されません。

導入方法
スクリプトを ~/.claude/hooks/prompt-guard.py に配置し、~/.claude/settings.json に登録します。スラッシュコマンド入力も検査対象に含めるため、UserPromptExpansion にも登録します。
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 ~/.claude/hooks/prompt-guard.py",
"timeout": 10
}
]
}
],
"UserPromptExpansion": [
{
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 ~/.claude/hooks/prompt-guard.py",
"timeout": 10
}
]
}
]
}
}
設定ミスの影響でプロンプト送信がブロックされ続けた場合は、以下のコマンドですべてのフックを無効化して起動できます。
claude --settings '{"disableAllHooks": true}'
参考:Hooksリファレンス
検証結果
サンプルデータによる検証
値はすべて架空のもので、トークン類は乱数から生成しました。ブロックされたものは、マスク後の文章をもう一度検査にかけると通過することを確認しています。
| 区分 | 件数 | 結果 |
|---|---|---|
| 秘密情報(止まるべき) | 15 | 15件ブロック |
| 個人情報(止まるべき) | 12 | 12件ブロック |
| 開発で通常入力するもの(止まってはいけない) | 11 | 11件通過 |
| 検出できないと分かっているもの | 7 | 7件通過(想定どおり) |
1. 止めたサンプル(秘密情報)
| サンプル | 入力 | 検出した種類 | Claudeに届く内容 |
|---|---|---|---|
| ログに出ていたアクセストークン | このエラーの原因わかる? Authorization: Bearer eyJhbGci… |
JWT | …Authorization: Bearer **** |
| OAuthのトークンレスポンス | {"access_token": "eyJhbGci…", "refresh_token": "…", "expires_in": 1800} |
JWT、パスワード等の値 | {"access_token": "****", "refresh_token": "****", "expires_in": 1800} |
| AWSアクセスキー | aws configure で AKIA0NWJ… を入れた |
AWSアクセスキー | aws configure で **** を入れた |
| GitHubトークン(classic) | GH_TOKEN=ghp_9KQ7… で gh が動かない |
GitHubトークン | GH_TOKEN=**** で gh が動かない |
| GitHubトークン(fine-grained) | github_pat_3iEq… |
GitHubトークン | **** |
| Anthropic APIキー | ANTHROPIC_API_KEY=sk-ant-api03-wKDC… |
Anthropic APIキー | ANTHROPIC_API_KEY=**** |
| Slack Webhook URL | https://hooks.slack.com/services/TD46… |
Webhook URL | **** |
| 秘密鍵 | -----BEGIN OPENSSH PRIVATE KEY-----… |
秘密鍵 | **** |
| DB接続文字列のパスワード | DATABASE_URL=postgres://app:soCR…@db.internal:5432/app |
URL内のパスワード | DATABASE_URL=postgres://app:****:5432/app |
| .envの中身 | APP_CLIENT_ID=3f10…⏎APP_CLIENT_SECRET=MZyx… |
パスワード等の値 | APP_CLIENT_ID=3f10…⏎APP_CLIENT_SECRET=**** |
| curlのBasic認証 | curl -H 'Authorization: Basic dLp4…' https://api.example.jp/token |
認証ヘッダーの値 | curl -H 'Authorization: Basic ****' https://api.example.jp/token |
| 日本語のキー名 | 外部APIのクライアントシークレットは ANixJQMi… です(英字のみ24文字) |
パスワード等の値 | 外部APIのクライアントシークレットは **** です |
| Stripeのキー | sk_live_9MmR… |
Stripeキー | **** |
| 32桁の16進数 | api_secret c13b…(32桁) |
16進数の文字列 | api_secret **** |
| ダミー語を含むパスワード | password=MyTestLife2024! |
パスワード等の値 | password=**** |
2. 止めたサンプル(個人情報)
| サンプル | 入力 | 検出した種類 | Claudeに届く内容 |
|---|---|---|---|
| 問い合わせ文中のメール | 会員の yamada.hanako@mail.test さんがログインできないそうです |
メールアドレス | 会員の **** さんがログインできないそうです |
| 携帯番号 | 折り返しは 080-0000-1234 までとのこと |
電話番号 | 折り返しは **** までとのこと |
| 携帯番号(ハイフンなし) | tel: 07000001234 |
電話番号 | tel: **** |
| 固定電話 | 店舗の番号は 06-0000-1234 です |
電話番号 | 店舗の番号は **** です |
| 国際形式 | +81 80 0000 1234 |
電話番号 | **** |
| 全角数字の電話番号 | 080-0000-1234 |
電話番号 | **** |
| クレジットカード番号 | カード 4242 4242 4242 4242 で決済エラー |
クレジットカード番号 | カード **** で決済エラー |
| マイナンバー | マイナンバーは123456789018です |
マイナンバー | マイナンバーは****です |
| DBの会員レコード | {"userId": "c887…", "email": "k.suzuki@mail.test", "tel": "090-0000-4408"} |
メールアドレス、電話番号 | {"userId": "c887…", "email": "****", "tel": "****"} |
| 会員一覧のCSV | 10000001,sato.ren@mail.test,090-0000-2031(2行) |
メールアドレス×2、電話番号×2 | 10000001,****,****(2行とも) |
| テストコードのダミー値 | { email: "dummy_01@mail.test", tel: "090-0000-0000", password: "testPassword123" } |
メールアドレス、電話番号、パスワード等の値 | { email: "****", tel: "****", password: "****" } |
| ブロック時の画面表示を貼ったもの | 解除タグの説明文とOriginal prompt: const member = { email: "dummy_01@mail.test", tel: "090-0000-0000" }を含む画面表示 |
メールアドレス、電話番号 | Original prompt: const member = { email: "****", tel: "****" }(説明文中のタグでは解除されない) |
3. 通ったサンプル(検出できないと分かっているもの)
| サンプル | 入力 |
|---|---|
| 氏名と住所 | 山田花子さん(東京都千代田区丸の内9-9-9)から問い合わせ |
| 区切りを崩したメール | yamada.hanako at mail dot test |
| キー名のない乱数 | これ使って z6VW…(24文字) |
| 接頭辞のない長い乱数トークン | ZQ6g8NWc…(173文字) |
| 独自の接頭辞のキー | acme_live_2Cat… |
| Base64にくるんだ値 | ZXhhbXBsZTpQYXNzdzByZCE= |
| 画像(スクリーンショット) | 文字がないので検査されない |
この仕組みで防げないこと(限界事項)
- 非定型のテキストや画像内の文字
人名、住所、特定SaaS独自のトークン、キー名のないランダムな文字列、Base64文字列、画像(スクリーンショット)内の文字は検出できません。 - ファイル参照やツールによる読み取り
検査できるのは「入力欄へ直接打ち込んだプロンプト本文」のみです。@記法で参照したファイルや、ClaudeがReadツール等で自律的に読み込むファイルの内容は防げません。これらはpermissions.denyのRead(...)などで制限する必要があります(sandboxのdenyReadが効くのはBashなどのシェルコマンドだけで、Readツールや@での参照は止められません)。 - Claudeが自分で行う処理の中身
このフックが検査するのは、ユーザーが送信したプロンプトだけです。Claudeが自分で呼び出したサブエージェントへの指示や、WebFetchなどのツールで取得した内容は検査されません。
おわりに
UserPromptSubmitフックを使って、うっかり貼り付けた秘密情報や個人情報を送信前に止め、マスク済みの文章を用意する仕組みを作りました。
ただし、正規表現では拾えない情報もあり、フックが動かなかった場合はそのまま送信されます。あくまで「貼らないのが大前提で、うっかりミスを拾うための安全網」です。何が止まり、何がすり抜けるのかを把握したうえで使うことが大切です。
今後は運用しながら、防ぎたいものが見つかるたびにルールを改善していく予定です。参考になれば幸いです。






