DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Stop Claude Code Push Accidents at the Machine Level: Guarding Against Leaked API Keys and Wrong-Repo Pushes

This is part of my "Claude Code environment" series. Last time I wrote about getting Claude and Codex to collaborate on a single machine. This time we're at the exit point of that setup: stopping accidents at the machine level when you let an AI drive git.

Handing commit and push over to an AI agent is convenient, but two things are scary. (1) It carelessly sweeps up an API key or .env, and (2) it pushes to the wrong repository. Neither can be prevented by "being careful," so I block them mechanically with a PreToolUse hook.

Why watching Edit/Write isn't enough

Claude Code often has a hook that "scans for secrets when writing a file" (for Edit/Write). But that alone leaves a hole. Both git commit and git push go through the Bash tool. You need another gate not at the moment the file is written, but at the moment you stage and push. So I added a PreToolUse hook targeting the Bash tool.

Design: it rides on every Bash call, so return early first

This hook fires on every single Bash call. So the top priority is to "spend zero cost on non-git commands." If the string doesn't contain git at the very start, it exits immediately without ever launching Python.

# 粗い早期return: payload に 'git' が無ければ python を1度も起動せず即終了。
case "$INPUT" in
  *git*) ;;
  *) exit 0 ;;
esac
Enter fullscreen mode Exit fullscreen mode

Note: It's important to write hooks on the assumption that "every call is taxed." The old implementation launched python3 three times to parse JSON, which taxed every call by several hundred ms. I consolidated parsing into a single pass, and let the vast majority of calls that don't involve git pass straight through via the regex early return.

After that, it only enters the main processing when the tool is Bash, the command has a git word boundary, and it contains commit or push.

Guard 1: Ban hook bypass

The first thing to block is "disabling the hook" itself. Git commands containing a bypass flag like --no-verify are blocked.

if printf '%s' "$CMD" | grep -qE -- '(--no-verify|--no-gpg-sign|commit\.gpgsign=false)'; then
  block "hook bypass フラグが含まれています。明示依頼が無い限り使用禁止。"
fi
Enter fullscreen mode Exit fullscreen mode

Without this, the agent might learn the worst kind of workaround: "the hook rejected me, so I'll re-run with --no-verify attached." Before the gate itself, ban the act of removing the gate.

Guard 2: Check that no secrets slipped into the commit

On git commit, it scans the staged diff (git diff --cached). The key is to look only at "the diff that's about to be committed," not the whole working tree.

DIFF=$(git diff --cached 2>/dev/null || true)

# AWS Access Key の形
printf '%s' "$DIFF" | grep -qE 'AKIA[0-9A-Z]{16}' && block "AWS Access Key が含まれています"

# secret= "..." / api_key: "..." のような代入
printf '%s' "$DIFF" | grep -qE \
  '(secret|api_key|apikey|access_token|private_key|client_secret)[[:space:]]*[:=][[:space:]]*["'"'"'][A-Za-z0-9/+_=-]{20,}' \
  && block "シークレットらしき値が含まれています"

# .env がステージされている(.env.example はOK)
git diff --cached --name-only | grep -E '(^|/)\.env' | grep -qvE '\.(example|sample|template)$' \
  && block ".env ファイルがステージされています"
Enter fullscreen mode Exit fullscreen mode

That third .env check quietly earns its keep. It blocks .env while letting .env.example / .env.sample / .env.template through. It matches the reality that you want to publish the template but must never let the real file out.

Guard 3: Verify the destination owner on push

This is the one that helps me most personally. It verifies that the owner of the push destination repository is mine, and blocks pushes to any owner not on the allowlist.

ALLOWED_OWNERS="bokuwalily"   # 自分のGitHub owner(スペース区切りで複数可)
# ...
URL=$(git remote get-url "$REMOTE")
OWNER=$(printf '%s' "$URL" | sed -nE 's#.*github\.com[:/]+([^/]+)/.*#\1#p')
# owner が ALLOWED_OWNERS になければ block
Enter fullscreen mode Exit fullscreen mode

Why this is needed: when you fork an OSS project and tinker with it, someone else's repository ends up mixed into your remotes. Pushing there by mistake becomes an accident where your code flies off into a third party's repo. I've also renamed my account in the past, so I wanted to mechanically guarantee that "every push to anything other than my current owner gets stopped." When I intentionally push to another owner, I just add it to the allowlist.

Operation: fail-secure and visibility

There are two design principles.

  • fail-secure: when in doubt about the judgment, don't let it through. A block returns decision: block as JSON and exits 2.
  • Leave a legitimate escape hatch: provide "a path that passes if it's intentional," like the allowlist or the template extensions. If you seal it off completely, humans end up wrestling with the hook every time they do a legitimate operation.

On top of these two (commit scanning + push destination verification), I've separately built a one-command publishing flow that also adds output verification right before push (secret → remote → output verification → push → deploy → connectivity check), but the foundation is this hook.

Pitfalls I hit

  • It rides on every Bash call, so parse tax hurts → return early if there's no git, consolidate the Python parse into one pass
  • Scanning the whole working tree causes false positives → look only at the staged portion with git diff --cached
  • It even swept up and blocked .env.example → exclude template extensions
  • Fork work mixes someone else's repo into the remotes → verify the push destination owner against an allowlist
  • The agent bypasses with --no-verify → block the bypass flag itself

Summary

  • commit/push go through Bash, so separate from Edit/Write watching, you need a PreToolUse gate
  • Because it rides on every Bash call, return early if there's no git for zero cost
  • commit scans only the staged diff for secrets, blocks .env while letting templates through
  • push verifies the destination owner against an allowlist to stop wrong pushes and leaks into third-party repos
  • fail-secure + a legitimate escape hatch. Don't seal it off completely; only let intentional operations through

Next time, I'll write about the pipeline that pours every session's conversation log into long-term memory — Turning Conversation Logs into Obsidian Long-Term Memory.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)