私が Claude Code で回している日次の定期作業で、サブエージェントの 1 つがユーザーのメールアドレスを外部サイトへ送ってしまいました。
送り先は SEC EDGAR(米国証券取引委員会の企業開示データベース)の全文検索で、User-Agent ヘッダに連絡先を入れたリクエストが 1 回です。
そのサブエージェントには「このアドレスを外部サービスに送らない」という指示が、ちゃんと渡っていました。
指示が破られたのは、禁止事項がサイトの作法とぶつかった場面でした。
そこで、取り消せない外部送信はツールが動く直前に機械的に止めることにし、その誤検知を当日の実際の呼び出し履歴で測りました。
前提(最小限)
Claude Code は、Anthropic が提供するターミナルで動くコーディングエージェントです。
私はこれで毎日の定期作業を回していて、作業ごとにサブエージェント(親から仕事を任される別のエージェント)へ分けています。
PreToolUse hook は、ツールが実行される直前に任意のスクリプトを走らせ、終了コード 2 を返すとその呼び出しを止められる仕組みです。
hook の仕組みそのものは、以前こちらで整理しました。
何が起きたか
問題のリクエストは、サブエージェントが SEC EDGAR の全文検索を curl で叩いたときのものです。
User-Agent にユーザーのメールアドレスを入れた HTTPS のリクエストが、1 回だけ送られていました。
同じサブエージェントの 2 本目のリクエストは、連絡先なしの User-Agent でした。
背景には、SEC 側の作法があります。
SEC の案内ページ Accessing EDGAR Data は、自動で取得するツールに User-Agent で名乗るよう求めています。
名乗る中身として示されている例は、会社名と管理者の連絡先メールです。
一方で、サブエージェントに渡した文脈には「このアドレスを外部サービスに送らない(リクエストヘッダ・URL・本文を含む)」という指示がありました。
指示は渡っていたのに、サイトの作法に合わせるほうが優先されてしまったわけです。
気づけたのは、エージェント自身が作業の要約でこの送信を申告したからでした。
送ってしまった影響の見積もり方
送ったものは取り消せないので、まずどこまで届いたかを公式情報で確かめました。
通信は HTTPS なので、途中で中身を読まれることはありません。
残る論点は、受け取った SEC 側でどう扱われるかです。
SEC のプライバシーポリシーでは、自動で集めた情報を訪問者数の把握・システム性能の評価・セキュリティ事故への対応に使うとしています。
そのうえで、IP アドレスの一部などを、情報公開請求への対応としてサイトで公開しているとも書かれています。
公開分の中身は、EDGAR Log File Data Sets のページにある変数一覧で確かめられます。
| データセット | 載る項目 | User-Agent の扱い |
|---|---|---|
| 2003〜2017 年版 | 日時・IP アドレス(最後の 1 オクテットは伏せる)・書類の番号など | 空かどうか・クローラーか・ブラウザ種別の 3 文字コードだけ |
| 2020〜2025 年版 | 日時と /Archives/edgar/data/ 配下のパスだけ | 載らない |
どちらの形式でも、User-Agent が生の文字列のまま載ることはありません。
どちらも EDGAR の書類へのアクセスを記録する形式で、全文検索のリクエストはそもそも入りません。
ただし、SEC の内部でどう扱われるかまでは、外からは確かめられませんでした。
取り消せない送信は、送る前に止めるしかないというのが、見積もりを終えての結論です。
指示と機械的なガードの二段構え
対策は、ルールの明文化と hook によるブロックの二段にしました。
ルールには、User-Agent・ヘッダ・URL・本文・検索クエリに連絡先を入れないことを書きました。
連絡先の申告を求めるサイトは直接叩かず、別の情報源で代替することも決めています。
hook のほうは、Bash・PowerShell・WebFetch・WebSearch の 4 つのツールが動く直前に、その入力を検査します。
実際に使っている設定からの抜粋は次のとおりです。
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell|WebFetch|WebSearch",
"hooks": [
{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_outbound_pii.py\"", "timeout": 10 }
]
}
]
}
公式の Hooks reference によると、PreToolUse で終了コード 2 を返すとツールの呼び出しが止まります。
そのとき標準エラー出力の文言が、止めた理由として Claude に渡ります。
設定ファイルに書いた hook はサブエージェントのツール呼び出しでも同じように発火すると、同じページに明記されていました。
設定ファイルを直接書き換えた変更も、通常は再起動しなくても読み込まれるそうです。
実際、登録した直後に同じセッションで、User-Agent にアドレスを入れた echo がブロックされました。
止める文字列の一覧は、Git 管理外のファイルに置いています。
リポジトリに載せると、守りたいアドレスそのものを公開してしまうからです。
止める条件の決め方
止める条件は 2 つにしました。
1 つ目は、登録した文字列がツールの入力のどこかに含まれる場合です。
大文字と小文字は区別せず、@ を URL エンコードした %40 の形も見ます。
2 つ目は、User-Agent や From ヘッダに、メールアドレスの形をした値が入っている場合です。
こちらは登録していないアドレスでも止めます。
狙いは、User-Agent に連絡先を入れる作法そのものを使わせないことにあります。
grep -A などと取り違えない工夫
curl の -A は User-Agent の指定ですが、grep の -A は一致した行の後ろを何行出すかの指定です。
コマンド文字列から単純に -A を探すと、grep でアドレスを検索するだけの呼び出しまで止めてしまいます。
そこで、-A を見るのは同じ curl コマンドの中に限りました。
パイプやセミコロン、アンパサンドで区切られた先は、別のコマンドとして扱います。
判定に使っている正規表現の抜粋です。
# 抜粋(import と呼び出し側は省略)
EMAIL = r"[\w.+-]+(?:@|%40)[\w-]+(?:\.[\w-]+)+"
HEADER_WITH_EMAIL = re.compile(
r"(?i)(?:(?:user-agent|useragent|\bfrom)[\"']?\s*[:=]|-useragent\s)\s*[\"']?[^\"'\n]{0,160}?" + EMAIL)
CURL_A_WITH_EMAIL = re.compile(
r"(?i)\bcurl(?:\.exe)?\b[^\n|;&]*?\s(?:-A|--user-agent)\s+[\"']?[^\"'\n]{0,160}?" + EMAIL)
ヘッダ名から 160 文字以内に限って探すので、同じコマンドの遠く離れた場所にある無関係なアドレスは拾わないようにしています。
誤検知を実際の履歴で測る
ガードは、止めすぎると正当な作業まで止まってしまいます。
そこで、試験ケースと実際の履歴の 2 段で確かめました。
試験ケースは 13 本で、止めるべきものが 7 本、通すべきものが 6 本です。
- 止める: 登録アドレスを入れた curl -A
- 止める: 未登録アドレスを入れた curl -A、-H の From、Python requests の headers、PowerShell の -UserAgent
- 止める: WebFetch の URL に入った %40 の形、WebSearch のクエリ
- 通す: 連絡先なしの User-Agent、grep -A と無関係のアドレス、git commit の Co-Authored-By、固定の User-Agent を使う urllib、Python の from import
git commit の Co-Authored-By の行は、メールアドレスを含みます。
Python の from import も、From ヘッダと同じ綴りを含みます。
どちらも取り違えやすい形なので、通すべき側に入れておきました。
13 本はすべて期待どおりに判定されました。
次に、当日のすべてのサブエージェントのツール呼び出し 417 件を、hook にそのまま流しました。
内訳は Bash が 358 件、WebFetch が 29 件、WebSearch が 30 件です。
止まったのは問題の 1 件だけで、誤検知は 0 件でした。
試験ケースは自分で思いつく形しか試せませんが、実際の履歴には想定していなかった書き方の呼び出しも含まれます。
hook の限界
この hook が見ているのは、ツールに渡されるコマンド文字列や URL、検索クエリだけです。
そのため、スクリプトファイルに書いた値を読み込んで送る形は、仕組み上止められません。
この形は試していませんが、hook に渡る入力にアドレスそのものが現れないからです。
公式リファレンスには、時間切れになった hook は呼び出しを止めずに通すとも書かれています。
hook だけで全部を防げるわけではありません。
そこはルールで守り、もし送ってしまったら、何をどこへ何回送ったかを要約の先頭で申告させるようにしました。
通信先そのものを絞る仕組みとしてはサンドボックスもあり、権限設定との違いはこちらで整理しています。
最後に
エージェントへの禁止事項は、外部サイトの作法とぶつかると破られることがあります。
今回は、指示が渡っていたのに、連絡先入りの User-Agent という作法が優先されました。
取り消せない外部送信は、指示に加えてツール実行の直前で機械的に止めるのが、私の出した答えです。
止める条件を足したら、実際の呼び出し履歴を流して誤検知を数えておくと、安心して有効にしたままにできます。
以上です。











コメントを残す