I let Claude Code handle my config backups, and it leaked a GCP key to GitHub

Last time, I wrote about turning Obsidian into Claude Code’s external memory. Riding that momentum, I decided to back up Claude Code’s own scattered settings next — the global config under ~/.claude/, each project’s .claude/, and my own scripts in ~/scripts/. None of it was under version control, so it would all vanish if the machine died.

I consolidated it into dotfiles and had Claude Code automate the whole thing. It promptly leaked a GCP service account key to my own GitHub repo. Here’s the screwup, the recovery I did on the spot, and the guardrails I built so it never happens again.

What I was trying to do

Almost everything I wanted to back up already lived under ~/.claude/:

  • CLAUDE.local.md (the core of my workflow rules)
  • settings.json (permission settings)
  • skills/ (my custom skills)
  • each project’s .claude/ (gitignored, untracked)

I had an old dotfiles repo I’d abandoned seven years ago, so I made it private, rebuilt it from scratch, and folded my current .zshrc and friends into the same place. The mechanism is the same as the vault backup from last time: a script that one-way copies from home, with launchd auto-committing and pushing every 30 minutes.

The screwup

It worked fine at first. The trouble started when I added ~/scripts/ to the backup targets.

The script already had a secret-detection gate built in — scan for credential patterns before committing, and abort if it finds anything. But the scan only covered home/ and claude/. It never included scripts/, which I’d just added.

~/scripts/timetree-sync/ has a GCP service account key sitting in it (sa.json). It sailed straight through the scan, got committed, and got pushed to GitHub. Private repo or not, that means the entire private_key field went out to a public server.

There’s a second thing I’d missed, too. At no point in this did I ever say “go ahead and push.” I only asked to “back it up” and “automate it.” Claude Code took that request and, as a natural extension, built and ran the auto-push mechanism on its own. The scanning gap was the bug, but the trigger was a push nobody asked for.

Recovering on the spot

Luckily the key only lived in the most recent commit, so recovery was quick.

git rm --cached sa.json out.ics sync.log
git commit --amend --no-edit      # rebuild that commit without the key
git push --force-with-lease        # replace the remote history
git reflog expire --expire=now --all && git gc --prune=now  # purge old objects locally

Finally I swept every commit with git grep $(git rev-list --all) and confirmed BEGIN PRIVATE KEY was gone everywhere.

But that’s not where you get to relax. Once the key’s bytes have touched GitHub’s servers, rewriting history doesn’t matter — the iron rule is that the key itself has to be revoked. Deleting the old key and issuing a new one in the GCP console is still sitting on my manual to-do list.

Why it leaked, and what I learned

Break the cause down and there are three layers to it.

  1. I added a new source but forgot to add it to the scan target. “Widen the copy source” and “widen the inspection range” should have been one atomic change, not two
  2. The exclusion list was too thin. rsync’s excludes stopped at .venv and .env — nobody anticipated sa.json or *.pem
  3. The detection logic itself was sloppy, and fixing it led me into a different trap

Layers 1 and 2 are different stages entirely. Either the copy stage (rsync excludes) or the inspection stage (the scan) should have caught this, and both let it through. I thought I had defense in depth, but every layer had a hole facing the same direction.

The third point turned out to be the real lesson. My first pass at the scan included words like private_key and service_account. That triggered a storm of false positives — it flagged sync.py’s own code, and even the regex inside the scan script itself, and ground every backup to a halt. Credentials searched for by “words” hit way too much ordinary text and code. The right move is to target the actual shape of a real secret — things like -----BEGIN PRIVATE KEY----- or a token starting with ghp_ — not vocabulary.

Put the safety net in a layer humans control

A scan buried inside a script only catches commits that go through the script — a manual git commit walks right past it. So I added one more layer underneath: a git pre-commit hook. That intercepts the commit operation itself, so it fires every time, automated or manual.

Stacking up every defense I’d added by this point looks like this:

LayerGuardWhat it stopsScope
1rsync excludeNever copy .env, sa.json, *.pem in the first placeat backup copy time
2.gitignoreEven if it gets copied, don’t track it (a backstop)git tracking
3in-script scanDetect key material before commitcommits made through the script
4git pre-commit hookBlock on filename and key materialall commits, including manual ones

The hook works in two passes: it blocks on dangerous filenames like sa.json, *.pem, *.key, id_rsa*, and then scans the contents for the actual shape of a real token. For code-heavy repos I also run gitleaks alongside it. And I put the same hook into the Obsidian vault from last time’s external-memory post, too — a decision that came back to bite me.

Committing a file with a fake key in it gets blocked exactly as intended:

BLOCK[content] credential material in _leaktest.txt
[pre-commit] Aborting commit: detected sensitive content.

The key point is that the guardrail lives in the git layer instead of relying on the AI’s good intentions. Next time it tries the same mistake, it gets stopped mechanically, before the commit ever lands.

That same guard stopped a different backup

It’d be a nice story to end there, but there’s one more twist.

A while after adding the guard, I noticed the vault backup had stopped running. Turned out the hook I’d just installed was the culprit. The content scan was tripping on the very knowledge note where I’d written up this whole incident — because the note quoted the string -----BEGIN PRIVATE KEY----- as an example, not an actual key. Just an explanation. But the scan can’t tell the difference.

The vault’s auto-backup script is written with set -euo pipefail, so every time a commit got rejected, the whole thing just stopped there. Fifteen hours of changes had quietly piled up without me noticing.

The guard I’d built to prevent a leak had, in turn, silently killed a completely different pipeline.

The fix was simple: for a prose-heavy vault, drop the content scan and block on dangerous filenames only. A note that explains what a secret’s format looks like is bound to trip a content scan. Scanning isn’t suited to prose.

This is probably the single biggest lesson in this whole post. When you bolt a guard onto something that’s already running, don’t declare victory until you’ve verified it against real data, not a synthetic test. I skipped that step this time and ended up blocking my own backup. And the only reason I caught it was pure luck — a stray “wait, isn’t this not running?” moment. I nearly missed it entirely.

Failure becomes memory

One last thing that ties back to the previous post.

“It pushed without being asked.” That failure got written straight into mistakes.md, the external memory I described last time, as a single line:

2026-06-21: Pushed to remote via git without the user's explicit permission
Correct Action: Even if push is a natural extension of the request, confirm explicitly before executing it
Trigger: git push, or whenever building any kind of scheduled/automatic push mechanism

Starting next session, it reads that line before acting. Even if I say “back this up,” it’ll pause before pushing instead of just doing it. A leaked key, routed through external memory, turns into next time’s caution. Today added a second line too — the one about blocking my own backup. Two stumbles in one day, but both of them are now something I can dodge next time. That’s not a bad trade.

What matters when you hand work off to AI is probably two things: assume it will make mistakes, and put the safety net in a layer humans control — the git layer, in this case. And turn mistakes into memory so the next run avoids them ahead of time. This time I got both, back to back, out of a pair of screwups in the same afternoon. Cheap tuition, all things considered.