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つあった。
- 新しいソースを足したのに、スキャン対象に足し忘れた。コピー元を増やしたら検査範囲も同時に広げる、をセットにしていなかった
- 除外設定が甘かった。rsyncの除外が
.venv.env止まりで、sa.jsonや*.pemを想定していなかった - 検出ロジックが雑だった。これは直す過程で別の落とし穴も踏んだ
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層のガード)に置くこと。そして間違いを記憶に変えて、次に先回りで避けさせること。今回はその両方を、立て続けのやらかしから同時に得られた。授業料としては安い方だと思う。