Claude Codeに設定のバックアップを任せたら、GCPの鍵をGitHubに漏らした話

前回、ObsidianをClaude Codeの外部記憶にした話を書いた。その勢いで、今度は散らばったClaude Code自身の設定をバックアップしたくなった。~/.claude/ のグローバル設定、各プロジェクトの .claude/~/scripts/ の自作スクリプト。どれもバージョン管理されておらず、マシンが飛んだら消える。

これをdotfilesに集約してClaude Codeに自動化させたら、見事に自分のGitHubリポジトリへサービスアカウント鍵を漏らした。やらかしと、その場でやった復旧、そして同じことを二度と起こさないためのガードまでを書いておく。

やりたかったこと

バックアップ対象はほぼ ~/.claude/ 配下に集まっていた。

  • CLAUDE.local.md(連携ルール本体)
  • settings.json(権限設定)
  • skills/(自作スキル)
  • 各プロジェクトの .claude/(gitignoreされていて未追跡)

7年放置していた古い dotfiles リポジトリがあったので、それをprivateにして作り直し、現行の .zshrc 等と一緒にここへ集約することにした。仕組みは前回のvaultバックアップと同じで、ホームから一方向コピーするスクリプトを書いて、launchdで30分ごとに自動commit & pushする。

やらかした

最初はうまくいっていた。問題は ~/scripts/ を対象に足したときに起きた。

スクリプトには一応シークレット検出のゲートを入れてあった。コミット前に資格情報のパターンをスキャンして、見つかったら中断する仕組みだ。ところがそのスキャン対象が home/claude/ だけで、いま足したばかりの scripts/ を含めていなかった。

~/scripts/timetree-sync/ にはGCPサービスアカウントの鍵(sa.json)が置いてある。それがスキャンを素通りして、commitされ、GitHubにpushされた。privateリポジトリとはいえ、private_key フィールドをまるごと公開サーバに送り込んだことになる。

そしてもう一つ、見落としていた事実がある。俺はこの間、一度も「pushしていい」とは言っていない。「バックアップして」「自動化して」と頼んだだけだ。Claude Codeはその依頼の延長で、自動pushの仕組みまで仕込んで実行した。間違っていたのはスキャンの穴だが、引き金を引いたのは「頼まれてもいないpush」だった。

その場で復旧する

幸い鍵を含むのは直前の1コミットだけだったので、復旧はすぐ済んだ。

git rm --cached sa.json out.ics sync.log
git commit --amend --no-edit      # 該当コミットを鍵抜きで作り直す
git push --force-with-lease        # リモートを置き換える
git reflog expire --expire=now --all && git gc --prune=now  # ローカルの旧オブジェクトを物理削除

最後に全コミットを git grep $(git rev-list --all) で舐めて、BEGIN PRIVATE KEY がどこにも残っていないことを確認した。

ただし、ここで安心してはいけない。一度GitHubのサーバに鍵のバイトが渡った以上、履歴から消しても鍵そのものは無効化が鉄則だ。GCPコンソールで該当の鍵を削除して新しい鍵を発行し直す。これは手作業のTODOとして残してある。

なぜ漏れたか、何を学んだか

原因を分解すると3つあった。

  1. 新しいソースを足したのに、スキャン対象に足し忘れた。コピー元を増やしたら検査範囲も同時に広げる、をセットにしていなかった
  2. 除外設定が甘かった。rsyncの除外が .venv .env 止まりで、sa.json*.pem を想定していなかった
  3. 検出ロジックが雑だった。これは直す過程で別の落とし穴も踏んだ

1と2は層が違う。コピーの段階(rsyncの除外)でも、検査の段階(スキャン)でも止められたはずなのに、両方とも素通りした。多層で守っているつもりが、どの層にも同じ向きの穴が空いていた。

3つめが地味に学びだった。最初はスキャンに「private_key」「service_account」という単語も入れていた。すると今度は誤検知の嵐になる。sync.py のコードや、スキャンスクリプト自身の正規表現にまでヒットして、全部のバックアップが止まる。資格情報は「単語」で探すと文章やコードに当たりすぎる。-----BEGIN PRIVATE KEY-----ghp_ で始まる実トークンのような、実物の形だけを狙うのが正しい。

安全網は人間側の仕組みに置く

スクリプトの中のスキャンだけでは、スクリプトを通らない手動の git commit を素通りさせてしまう。そこで、もう一段下の gitのpre-commitフックでガードすることにした。これはコミットという操作そのものに割り込むので、自動でも手動でも必ず発火する。

ここまでに足した防御を層で並べると、こうなる。

ガード何を止めるか対象
rsync 除外.env sa.json *.pem をそもそもコピーしないバックアップのコピー時
.gitignore万一コピーされても追跡しない(保険)git 追跡
スクリプト内スキャンcommit 前に鍵materialを検出スクリプト経由の commit
git pre-commit フック名前と鍵materialで弾く手動を含むすべての commit

フックの中身は二段構え。sa.json *.pem *.key id_rsa* のような危険なファイル名で弾き、さらに中身を実トークンの形でスキャンする。コード主体のリポジトリでは gitleaks も併用する。そして同じフックを、前回の外部記憶であるObsidianのvaultにも入れておいた。この判断が、あとで裏目に出る。

偽の鍵を含むファイルでcommitを試すと、ちゃんと止まる。

BLOCK[内容] _leaktest.txt に資格情報material
[pre-commit] 機密を検出したため commit を中止しました。

肝は、ガードをAIの善意ではなくgit層の仕組みに置いたことだ。次に同じミスをしかけても、コミットの手前で機械的に止まる。

そのガードが、別のバックアップを止めた

ここで終われば気持ちのいい話なんだけど、もう一段オチがある。

ガードを入れてからしばらくして、vaultのバックアップが止まっているのに気づいた。調べたら、さっき入れたばかりのフックが犯人だった。中身スキャンが、この一連の話を書いた知見ノート自身に反応していたのだ。ノートの中に説明として -----BEGIN PRIVATE KEY----- という文字列を書いていたせいだ。本物の鍵じゃなく、ただの解説。でもスキャンに区別はつかない。

vaultの自動バックアップは set -euo pipefail で組んであるので、コミットが弾かれるたびにそこで止まる。気づかないうちに、15時間ぶんの変更が静かに溜まっていた。漏洩を防ぐために入れたガードが、今度は別のパイプラインを黙って止めていたわけだ。

直し方は単純で、文章主体のvaultでは中身スキャンをやめ、危険なファイル名だけ弾くようにした。秘密の「形式」を解説するノートは、中身スキャンに普通に引っかかる。スキャンは文章に向かない。

たぶんこれがこの記事で一番大事な学びだ。動いているものに割り込むガードを足したら、作り物のテストじゃなく実物のデータで通るか確かめてから「できた」と言う。今回はそれを怠って、自分で自分のバックアップを止めた。しかも止まっていることに気づいたのは偶然で、ふと「動いてなくない?」と引っかからなければ、危うく見逃すところだった。

失敗が記憶になる

最後に、前回の記事と一本につながる話を。

「頼まれてもいないのにpushした」。この失敗は、前回書いた外部記憶の mistakes.md にそのまま一行刻まれた。

2026-06-21: ユーザーの明示許可なくリモートへ git push した
Correct Action: push は依頼の延長でも、実行していいと明示確認を取ってから
Trigger: git push、または定期 push の仕組みを作るとき

次のセッションからは、この一行を読んでから動く。「バックアップして」と言われても、pushの前に一度立ち止まるようになる。鍵を漏らした失敗が、外部記憶を経由して次の慎重さに変わるわけだ。今日はもう一つ、自分でバックアップを止めた件も同じように一行になった。一日で二回つまずいたけれど、二回とも次に避けられる形で残せたのは悪くない。

AIに作業を任せる時代に大事なのは、たぶん2つだ。間違える前提で、安全網を人間側の仕組み(git層のガード)に置くこと。そして間違いを記憶に変えて、次に先回りで避けさせること。今回はその両方を、立て続けのやらかしから同時に得られた。授業料としては安い方だと思う。