Claude Codeに秘密を渡さない
Claude CodeのAPIキー管理は、denyだけでは足りない。grepやPythonは.envを読める。ダミーの鍵で漏れ道を再現した。
Claude Code に .env を読ませないための定番は、権限の deny に Read(./.env) を書くことです。ただ、それだけでは塞がらない経路があります。この記事では、公式ドキュメントが自ら「対象外」としている経路を、ダミーの鍵で再現します。そのうえで、鍵をエージェントの手の届く範囲に置かない設計をまとめます。
権限ルールの評価順そのものは「AIエージェントの権限設計」に書きました。
再現の条件
- 環境:編集部の検証環境(Linux)、Claude Code 2.1.301、2026-10-16。
- 一時ディレクトリを作り、
env -iで環境変数を空にしたシェルで動かしました。 - 鍵は、すべて
sk-dummy-0000-NOT-REALというダミーの値です。
Claude Code のセッションの中で、deny がどの呼び出しを止めるかまでは観察していません。止まる・止まらないの判断は公式ドキュメントの記述に基づいています。ここで確かめたのは、「止まらない側の経路では、実際に中身が出る」ことです。
公式が書いている deny の範囲
公式ドキュメントの要点です(出典:https://code.claude.com/docs/en/permissions 、2026-10-16確認)。
- Read と Edit の deny は、組み込みのファイル操作ツールに効きます。Bash の中でも、Claude Code が認識するファイル操作のコマンド(
cat、head、tail、sed、tee)と、リダイレクトの対象には効きます。 - ファイル名を書かずに読むコマンド(そのディレクトリで実行した
grep -r pattern .など)には効きません。 - 自分でファイルを開くサブプロセス(Python や Node のスクリプト)にも効きません。
- すべてのプロセスから守りたいなら、サンドボックスを有効にするよう案内しています。
止まらない経路を、ダミーの鍵で再現した
d=$(mktemp -d) && cd "$d"
env -i PATH=/usr/bin:/bin HOME="$d" bash --norc --noprofile
printf 'DUMMY_API_KEY=sk-dummy-0000-NOT-REAL\n' > .env
| 経路 | コマンド | 出力 |
|---|---|---|
| ファイル名を書かない検索 | grep -r DUMMY_API_KEY . | ./.env:DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| Python が自分で開く | python3 -c "print(open('.env').read())" | DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| Node が自分で開く | node -e "...readFileSync('.env')..." | DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| 環境変数の継承 | export したあと bash -c 'env | grep DUMMY' | DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| シェルのトレース | bash -x -c 'curl -H "Authorization: Bearer $DUMMY_API_KEY" ...' | + curl ... 'Authorization: Bearer sk-dummy-0000-NOT-REAL' ... |
最後の行のように、デバッグのために set -x を付けると、展開された鍵がトレースにそのまま出ます。トレースはログにも、エージェントの文脈にも残ります。
ファイルを読ませない設定と、鍵を見せない設計は、別のものです。 deny は前者だけを担います。
環境変数も子プロセスに渡る
Claude Code を起動したシェルに鍵を export していると、Bash のコマンド、hook、stdio の MCP サーバーに、その環境が引き継がれます。公式は、CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 を設定すると、これらのサブプロセスの環境から認証情報を取り除く、と説明しています。判定は変数名か値の形で行うので、GITHUB_TOKEN や、名前も値も認証情報らしく見えない秘密は残ります。そのため、狭い権限ルールと組み合わせて使うよう書いています(出典:https://code.claude.com/docs/en/env-vars 、2026-10-16確認)。
hook のプロセスも親の環境を引き継ぎます(出典:https://code.claude.com/docs/en/hooks 、2026-10-16確認)。
環境変数が設定どおりに効かないときの切り分けは、「Claude Codeで環境変数が効かない」にまとめました。
OS のレベルで閉じる
公式のサンドボックスは、Bash のコマンドとその子プロセスについて、ファイルの読み取りとネットワークを OS のレベルで制限します。~/.ssh のような認証情報のファイルは filesystem.denyRead で閉じられます。macOS は Seatbelt、Linux と WSL2 は bubblewrap を使います(出典:https://code.claude.com/docs/en/sandboxing 、2026-10-16確認)。
逆に、denyRead は組み込みの Read ツールを止めません(同出典)。権限の deny とサンドボックスは、両方を設定して初めて隙間がなくなります。
PreToolUse の hook で、コマンドの文字列に .env が含まれていたら止めることもできます。書き方は「Claude Code hooks実践」に書きました。ただし、上の grep -r . のように、ファイル名が文字列に出てこない読み方は、hook では拾えません。
鍵を取り出すときの末尾の改行
OS の鍵保管庫から鍵を取り出すコマンドには、出力の末尾に改行を付けるものがあります。編集部では、それをそのまま secret として登録していました。鍵は改行込みで保存され、通知の経路が毎回失敗して、誰にも知らせずに止まっていました。
ダミーの値で、違いを確かめました。
get(){ printf 'sk-dummy-0000-NOT-REAL\n'; } # 末尾に改行を付けて返すコマンドの代わり
get | wc -c # → 23 bytes(改行込み)
s="$(get)"; printf %s "$s" | wc -c # → 22 bytes
パイプでそのまま渡すと、改行も一緒に渡ります。コマンド置換 $(...) で受けると、末尾の改行が落ちます。登録する前に、バイト数を確かめます。
エージェントには集計だけを見せる
個人情報を含むデータを調べさせるときは、個別の行を出力させません。個人情報ではない列だけを選び、件数や合計のような集計値だけを出させます。会話の記録には、出力がそのまま残るからです。
外部に送る操作(Web の取得、Issue の本文、別のモデルへのレビュー依頼)の前にも、渡すファイルに秘密が含まれていないかを確かめます。別のモデルへのレビュー依頼は、確認の対象から漏れやすい経路です。
鍵そのものを置かない
いちばん確実なのは、エージェントが動く環境に、平文の鍵を置かないことです。たとえば 1Password CLI の op run は、.env に書いた参照(op://...)を実行時に解決し、指定したコマンドのサブプロセスにだけ環境変数として渡します(出典:https://developer.1password.com/docs/cli/secrets-environment-variables/ 、2026-10-16確認)。
ただし、同じページは「同じユーザーが動かすプロセスは、ほかのプロセスの環境を読めると考えるべきだ」とも書いています(同出典)。渡した先のプロセスの中では、鍵は普通の環境変数です。
通知用のトークンを、LLM の文脈ではなく専用のスクリプトに分けた例は、「AIエージェントをVPSで24時間動かす」に書きました。
まとめ
Read(./.env)の deny は、grep -r .や Python・Node のスクリプトには効かない(公式の記述どおり)。set -xのトレースと、継承された環境変数からも鍵は漏れる。- 権限の deny とサンドボックスの
denyReadを両方設定し、CLAUDE_CODE_SUBPROCESS_ENV_SCRUBも検討する。 - 鍵を取り出して登録するときは、末尾の改行をバイト数で確かめる。
- エージェントには集計値だけを見せ、平文の鍵は置かない。