Max Nardit
← 記事
15 mindev

Claude Code の「Don't ask again」と settings.local.json: allow ルールに残ったパスワードの見つけ方と、変えるべきこと

現在の Claude Code では、確認ダイアログがルールを保存する前にコマンドの大半をワイルドカードに置き換えます。それでも、プログラム名の前に書いたパスワードは残ります。セッションのトランスクリプトにはコマンド全体が記録されるので、いちばん確実な対策は、そもそも秘密情報を含まないコマンドラインにすることです。

目次

11 月 5 日、Apiiro はある集計を公開しました。Claude Code を使っている人の多くは、自分のノート PC で 5 分あれば同じことを確かめられます。GitHub 上にある .claude/settings.local.json の公開コピー 104,185 件のうち、1,122 件に Apiiro が「本物の秘密情報」と分類するものが含まれていました。データベースのパスワード、クラウドのキー、API トークン、root での SSH ログインです。ファイルの git 履歴、共有用の settings.json、コミットされたエージェントのトランスクリプトまで含めると、合計は 1,994 リポジトリ、63 組織にわたる 2,539 件の異なる秘密情報になります。Apiiro はどれも実際には試していません。ここでの「本物」とは、既知のキー形式であるか、公開ホストと並んだパスワードであるという意味で、ログインできることを確認したわけではありません。ただし 1 社は、自社のキーが有効だったと認めています。

仕組みはありふれたものです。Claude Code はシェルコマンドを実行する前に確認を求め、そこで「Yes, and don't ask again」を選ぶと、その承認がこのファイルのパーミッションルールになります。コマンドにパスワードが含まれていれば、ルールにも含まれることがあります。現在のリリースが何を保存するのかを見るため、Claude Code 2.1.319 でダミーの認証情報を使って確認ダイアログを再現しました。結果はレポートの説明とは少し違い、その違いによって探すべきものが変わります。

Claude Code の「Don't ask again」は settings.local.json に何を書き込むのか

allow ルールです。git リポジトリのルートにある .claude/settings.local.json に書き込まれ、そこで開く今後のすべてのセッションに適用されます。2.1.319 での筆者のテストでは、提案されたルールはコマンド全体ではなく、ワイルドカード付きのプレフィックスでした。そして残ったのは、プログラム名より前に書かれていたものすべてで、パスワードを持つ環境変数も含まれていました。

パーミッションのドキュメントによれば、Bash の承認は「permanently per repository and command」で保存され、ダイアログがコマンドのプレフィックスを提案するときは、スペース区切りの * 形式で書き込みます。新しいリポジトリで Manual モードにして、6 つのコマンドに対してダイアログが何を提案したかを示します。1 つ目は、代入とフラグ指定のパスワードを同じコマンドに入れるため、わざと不自然にしてあります。

command:  PGPASSWORD=hunter2placeholder sshpass -p hunter2placeholder ssh app@db.example.invalid true
offered:  PGPASSWORD=hunter2placeholder sshpass *
 
command:  PGPASSWORD=hunter2placeholder psql -h db.example.invalid -U app -c 'select 1'
offered:  PGPASSWORD=hunter2placeholder psql *
 
command:  DATABASE_URL=postgresql://app:hunter2placeholder@db.example.invalid/app npm run migrate
offered:  DATABASE_URL=postgresql://app:hunter2placeholder@db.example.invalid/app npm run *
 
command:  mysql -h db.example.invalid -u app -phunter2placeholder -e 'select 1'
offered:  mysql *
 
command:  curl -H "Authorization: Bearer sk-placeholder-0000" https://api.example.invalid/v1/me
offered:  curl *
 
command:  psql postgresql://app:hunter2placeholder@db.example.invalid/app -c 'select 1'
offered:  psql *

1 つ目を承認すると、ファイルはこうなりました。

json
{
  "permissions": {
    "allow": [
      "Bash(PGPASSWORD=hunter2placeholder sshpass *)"
    ]
  }
}

つまり sshpass -p のパスワードはワイルドカードに吸収され、PGPASSWORD= のほうは残りました。これはドキュメントのマッチング規則と一致します。組み込みの既知の安全なリストに入っていない変数については、allow ルールは「won't match past an assignment」とされています。次回の実行にマッチさせるには、プレフィックスに代入を値ごと含めるしかありません。

Apiiro はルールを「コマンドそのもの」と説明していますが、集計されたファイルは、それぞれの開発者が当時使っていたバージョンが書いたものです。ダイアログがいつからプレフィックスを提案するようになったのかは、筆者のテストからはわかりません。ですから、見つけたファイルは古さに関係なくすべて読んでください。ワイルドカードにも別の代償があります。curl * と mysql * には秘密情報は含まれていませんが、そのリポジトリでは以後すべての curl、すべての mysql を承認します。画面に出ていた 1 つのコマンドよりはるかに広い許可です。Claude Code の Bash ルールがマッチするのはプログラムではなくコマンドのテキストなので、広い allow は、ダイアログには決して表示されない形でも広く効きます。

Claude Code が無視しているはずの settings.local.json が git に入るのはなぜか

Claude Code が無視の設定をするのは、リポジトリではなく、あなたのグローバルな git 設定だからです。無視のエントリは 1 台のマシンにしか存在せず、プロジェクトの中にそれを他の場所へ運ぶものは何もありません。

設定のドキュメントは具体的です。まだこのファイルを無視していない git リポジトリで Claude Code が初めてファイルを書き込むとき、グローバルな excludes ファイルに **/.claude/settings.local.json を追加します。グローバルな git 設定で core.excludesFile が絶対パスか ~ 始まりのパスに設定されていればそれが使われ、そうでなければ $XDG_CONFIG_HOME/git/ignore、その変数が未設定なら ~/.config/git/ignore です。ファイルを手で作った場合は、自分で .gitignore に追加するよう案内されます。

Apiiro は、これでは足りない場面を挙げています。あとからリポジトリになったフォルダ、別のマシンでのクローン、誰かがすでにコミットしたファイルです。さらに git add -f、広い範囲をコピーして作った Docker イメージ、プロジェクトフォルダの zip もあります。そして、エージェント自身がファイルをコミットすることも少なくありません。このファイルに秘密情報があり、Apiiro が追跡できた 1,105 リポジトリのうち 539 件では、Claude が共同作成者になっているコミットがファイルを変更していました。

git がファイルを追跡するようになると、Claude Code はそれをリポジトリから提供された設定として扱い、フォルダを信頼するまで allow ルールを保留します。これはリポジトリをクローンした人をあなたの承認から守ります。パスワードについては何も守りません。

コミットされた settings.local.json をリポジトリでどう確認するか

リポジトリごとに git コマンドを 3 つ、そのあと組織全体でコード検索です。リポジトリのルートで実行してください。3 つ目のコマンドはローカルにある履歴しか検索できないので、シャロークローンではなく完全なクローンで実行します。

shell
git check-ignore -v --no-index .claude/settings.local.json
git ls-files ':(glob)**/.claude/settings*.json'
git log --all --oneline -- ':(glob)**/.claude/settings*.json'

check-ignore はファイルを無視しているファイルと行を示し (--no-index を付けると追跡中のファイルにも答えます)、ls-files は git が現在追跡しているコピーを、log はそれに触れたことのあるすべてのコミットを一覧にします。glob は入れ子になったコピーもカバーします。v2.1.211 より前の Claude Code は、セッションを開始したディレクトリにローカルファイルを置いていたので、これは重要です。共有用の settings.json もあわせてカバーします。

筆者のテストリポジトリでは、ファイルをコミットしてから追跡を外したあと、ls-files は何も出力せず、log には両方のコミットが残っていました。心配すべきなのはこのケースです。ファイルはツリーから消えていても、パスワードは履歴に残り、そのコミットを受け取ったすべてのクローンとフォークにも残っています。Apiiro は、ファイルの過去のバージョンにしか存在しない秘密情報を 734 件、同じ秘密情報を持つ 74 リポジトリのフォークを 211 件見つけています。

組織については、GitHub のコード検索で現在のコピーを見つけられます。

shell
Q='.[] | select(.path | test("(^|/)\\.claude/")) | .repository.nameWithOwner + ":" + .path'
gh search code '{' --owner your-org --filename settings.local.json --limit 1000 --json repository,path -q "$Q"
gh search code '{' --owner your-org --filename settings.json --limit 1000 --json repository,path -q "$Q"

{ を入れているのは、検索に検索語が必要で、どの JSON ファイルにも { があるからです。フィルターは .claude フォルダ内のファイルだけを残します。--filename は managed-settings.json のような名前にもマッチするためです。gh は上限を上げないと 30 件しか返しません。また、コード検索はインデックス済みリポジトリのデフォルトブランチしか読まないので、結果が空でもほとんど何も証明しません。履歴については、各リポジトリで git log の行を実行する必要があります。共有用の .claude/settings.json も確認してください。チームは意図してこれをコミットしますし、Apiiro はそのうち 298 件で本物の秘密情報を見つけています。大半は allow ルールの中でした。

自分のマシンにある Claude Code の allow ルールからパスワードを見つけるには

ファイルのコピーをすべて見つけ、秘密情報スキャナーが見逃す形を検索します。このファイルの大半は GitHub には届きません。そしてプロジェクトフォルダにある平文の JSON ファイルは、認証情報を盗むマルウェアが読みに行く場所です。Apiiro によれば、npm ワームの Shai-Hulud は被害者のファイルに対して TruffleHog を実行していました。

shell
PAT='PASSWORD=|sshpass -p|-p[^ "*)]{3,}|Bearer [^ "*]+|://[^/:@ ]+:[^@ ]+@|(api[_-]?key|token|secret)='
find ~ -path '*/.claude/settings*.json' -not -path '*/node_modules/*' -exec grep -lEi "$PAT" {} + 2>/dev/null

出力するのはマッチした内容ではなくファイル名です。秘密情報が画面に流れないようにするためと、これを実行したセッションが自分のトランスクリプトに秘密情報を写し取らないようにするためです。表示されたファイルは、エージェントの外のエディタで開いてください。パターンはわざと粗くしてあります。PGPASSWORD= や mysql -p の後のパスワード、Bearer ヘッダー、接続文字列の中のパスワード、よくある token= の代入を拾う一方で、--port、--prefix をはじめハイフン付きの単語も大量に拾うので、ホームディレクトリが大きければ誤検知を覚悟してください。それが専用ツールの取りこぼしを拾うための代償です。Apiiro は 104,185 件すべてに gitleaks と TruffleHog をかけました。本物の秘密情報を含む 1,122 件のうち、gitleaks が検出したのは 564 件、TruffleHog は 643 件で、215 件はどちらにも検出されませんでした。コマンドに打ち込んだパスワードは、ありふれたコマンドラインの中のありふれた単語であり、マッチさせるプロバイダーのプレフィックスがありません。

次にセッションのトランスクリプトを検索します。トランスクリプトにはルールより多くのことが残っています。Claude Code は各セッションを ~/.claude/projects/ 以下に JSON Lines として保存し、ツール呼び出しは Claude が書いたとおりに記録されます。筆者のテストでは、ルールでは * に置き換えられていた sshpass -p のパスワードが、トランスクリプトには完全な形で残っていました。拒否したコマンドも同様です。

shell
grep -rlEi "$PAT" ~/.claude/projects/ 2>/dev/null

こちらは「Don't ask again」を一度もクリックしたことがなくても重要です。bypassPermissions モードのように確認ダイアログが出ない場合、ルールは書き込まれませんが、トランスクリプトにはコマンドがそのまま記録されます。

settings.local.json に秘密情報を見つけたら、最初に何をすべきか

ローテーションです。ルールを削除しても、ファイルの追跡を外しても、履歴を書き換えても、消えるのは自分のコピーだけで、他のコピーは 1 つも消えません。ですから、後片付けに意味が出るのは、その認証情報が使えなくなってからです。Apiiro の推奨する順序も同じです。まずローテーション、次に後片付け、そしてファイルがこれまでに保持したことのあるすべての認証情報をローテーションします。

そのうえで、ls-files でファイルが追跡されていたリポジトリごとに次を実行します。

shell
git rm --cached .claude/settings.local.json
echo '.claude/settings.local.json' >> .gitignore
git add .gitignore
git commit -m "Stop tracking Claude Code local settings" -- .gitignore .claude/settings.local.json

コミットに pathspec を付けているのは、ほかにステージしていたものを巻き込まないためです。追跡されていたかどうかに関係なく、すべてのリポジトリで、秘密情報を含むルールを /permissions かファイルの編集で削除してください。そして .gitignore のその行は残しておきます。そうすれば、保護が 1 台のマシンの git 設定に閉じず、コードと一緒に移動します。秘密情報を消すために履歴も書き換える場合は、ローテーションのあとに行い、衛生上の作業と考えてください。クローンやフォークには、すでに古いコミットがあります。

Claude Code のパーミッションルールに秘密情報を入れないためには

秘密情報をコマンドラインに載せないことです。教科書どおりの方法である $VAR 参照は、筆者のテストでは毎回確認ダイアログが出ました。認証情報ファイルや用途を固定したスクリプトでは出ませんでした。

Apiiro の助言は、多くの助言と同じく、秘密情報を $VAR として参照し、ルールに値ではなく名前が記録されるようにするというものです。筆者が試した 1 つのコマンド、psql "$DATABASE_URL" -c 'select 1' では、ダイアログに「don't ask again」の選択肢そのものが出ませんでした。Bash(psql *) を手でファイルに書き込んでも (Claude Code は保存時にファイルを再読み込みし、数分後に同じ方法で追加したルールも再起動なしで適用されました)、同じコマンドはやはり確認を求め、次の一行が表示されました。

A variable in this command can't be checked before it runs

「このコマンド内の変数は実行前に確認できません」という意味です。パーミッションシステムとしては妥当な規則です。変数が何に展開されるかは、コマンドのテキストからは Claude Code にはわからないからです。ただしこれは、$VAR の形では確認が続くということでもあります。確認が続けば、値をコマンドに直接書く形に戻ってしまうのは十分ありえます。

よりよい対策は、ツールに自分で認証情報を見つけさせることです。psql は ~/.pgpass を読みます。サーバーごとに hostname:port:database:username:password の行を 1 つ書くファイルで、パーミッションが厳しくなければ無視されるので、chmod 0600 ~/.pgpass を実行してください。これでコマンドはパスワードを含まなくなります。ただし、コマンドの他の部分にも含まれていないことが前提で、接続 URI からもパスワードを外してください。平文のファイルであることに変わりはなく、プロジェクトの外にあるというだけです。sshpass -p は SSH キーで完全に置き換えられます。

そうしたファイルがない場合は、ラッパースクリプトに認証情報の取得を任せられます。筆者のテストでは、自分の環境変数から DATABASE_URL を読むスクリプトを次のルールで承認すると、確認なしで実行されました。

json
{
  "permissions": {
    "allow": [
      "Bash(./scripts/db-query.sh *)"
    ]
  }
}

このルールが名指ししているのは認証情報ではなく自分で書いたファイルですが、スクリプトが引数で行うことはすべて許可しますし、スクリプトは承認後に変わりえます。任意のテキストを psql -c に渡すラッパーは、バックスラッシュコマンドも含めてクライアントをまるごと渡すのと同じです。スクリプトには固定した役割を 1 つだけ持たせ (ステータス確認、名前の決まったレポートなど)、自由形式の入力は受け付けず、その役割しかできないデータベースのログインを与え、* のない完全一致のルールを選んでください。また、スクリプトは決して秘密情報を出力してはいけません。出力もトランスクリプトに残るからです。

小さな習慣も 2 つ役立ちます。Read(./.env) の deny ルールを置けば、Claude のファイルツールが値をコンテキストに読み込むのを防げます。ただしドキュメントは、スクリプトが自分でファイルを開く場合はカバーしないとはっきり書いています。そして、秘密情報を settings.local.json の env ブロックに移さないでください。それは、この記事全体が扱っているファイルに秘密情報を移し替えるだけです。サンドボックスは別の層です。サンドボックスはコマンドが届く範囲を制限しますが、すでにツール呼び出しやルールに書き込まれた秘密情報を伏せてくれるわけではありません。

Claude Console の IP 許可リストは、漏れた Claude API キーに効くか

Claude Platform の API キーについては、最後の防衛線として効きます。漏れたキーが使える期間ではなく、使える場所を制限するものです。Apiiro の集計にあるデータベースのパスワードや、Claude 以外の認証情報には何の効果もありません。

IP 許可リストのドキュメントによると、これはベータ機能で、組織の管理者またはオーナーが Console の Identity and access で有効にします。対象は Messages、Batches、Token Counting、Models、Files、Skills の各エンドポイント、Claude Managed Agents のエンドポイント、そしてその組織だけを対象とする Admin API キーと Compliance API キーです。リストの外からのリクエストには 403 の permission_error が返ります。Bedrock、Claude Platform on AWS、Google Cloud、Microsoft Foundry は対象外です。エントリは IPv4 で /8、IPv6 で /32 より広くはできず、変更の反映には最大 15 分かかります。向いているのは、送信元アドレスが固定されたサーバーや CI で使うキーで、ネットワークが変わるノート PC には向きません。Anthropic 自身のページも、あわせて短いキー有効期限か Workload Identity Federation を推奨しています。

Claude Code の allow リストは、もともと秘密情報を置く場所ではなかった

ルールファイルは目に見えるコピーで、トランスクリプトは完全なコピーです。2.1.319 のダイアログは、提案するルールから引数の大半を外しますが、今回のテストでは、プレフィックスとして書かれたパスワードはやはり残しました。ダイアログが何を保存するかに左右されない対策は、秘密情報を一切含まないコマンドラインです。そうすれば、ルールにも、そのコマンドのトランスクリプトの記録にも、コミットにも、残すものがありません。次のバージョンの提案がどうなっても同じです。出力やファイルの読み込みを通じて秘密情報がセッションに入ることはまだあるので、これで塞がるのは 1 つの経路、Apiiro が数えた経路だけです。もう読まなくなった確認ダイアログは、その判断をする場所として不向きです。だからこそ、人が注意を向けるべき承認は、まれなものだけなのです。

01 / ディスカッション

コメント欄はありません。議論は X で行っています。

Max Nardit@mnardit

02 / ほかの記事

  1. Claude Code が User-Agent にあなたのメールアドレスを入れる問題:privacy mod が確認するもの、hook と allowlist が埋める穴

    Claude Code があなたのメールアドレスを Web サイトに送る機能は、誰も作っていません。モデルはコンテキストの中にアドレスを見つけ、スクリプトでアクセスするクライアントに連絡先を求めるサイトに出会い、親切に応じます。提案されている対策はどれも、外に出ていくアドレスをフィルタするものです。だから問題は、どのフィルタがモデルの判断に左右されないか、そしてそれぞれがどの漏れ道を塞がずに残すかです。

  2. コーディングエージェントへのプロンプトインジェクション事例:テキストはどこから入り、何がそれを止めたのか(止めたものがあったとすれば)

    公開された 9 件の事例を、攻撃者の言葉が入ってきた入口ごとに分類しました。ほとんどの事例で、攻撃の最中に持ちこたえたものはありません。その後の修正はすべて同じ種類でした。決定論的なチェックを向け直すか、機能を取り除くかです。失敗したのがモデルの判断だった場合、向け直す対象はありませんでした。

  3. Cursor のマーケットプレイスで配布された Claude Code 拡張機能のバックドア: 何が実行され、アンインストール後に何が残り、なぜバージョン番号では受け取ったビルドを見分けられないのか

    改ざんされたビルドには Anthropic のパブリッシャー名が付き、うち 1 つは Anthropic が実際にリリースしたバージョン番号を使っていました。パブリッシャー名もバージョン番号も、そのバイト列を誰がビルドしたかは示しません。そして肝心の仕込まれたものは、拡張機能を消したあともマシンに残ります。