An AI coding agent with shell access will, eventually, try to commit something
it shouldn't. Not maliciously — it runs git add -A to "save progress,"
and an untracked .env goes in with everything else. The commit message
says chore: sync. The next git push sends your database
password to a remote you can't fully un-send it from.
Humans develop a reflex: you glance at git status before staging,
and an unexpected .env jumps out. Agents don't have that reflex unless
you give it to them. They pattern-match "stage the work" to git add -A or
git add ., both of which sweep up every untracked file in the tree —
secrets, local configs, key material, and (in repos with worktrees) gitlinks to
other sessions.
.gitignore only protects files that were never tracked. The dangerous cases slip
through anyway: a .env.local variant not in your ignore patterns, a key
pasted into a normally-tracked config file, or a secret in a file the agent created
this session. You need a check on the content of the outgoing commits, not
just filenames.
The right place to catch a secret is the moment before it leaves your machine —
at git push, against the commits you're actually about to send
(@{u}..HEAD), not the whole history and not just the working tree.
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)
range=${upstream:+$upstream..HEAD}
# names: catch .env and key material in outgoing commits
git diff --name-only "$range" | grep -qE '(^|/)(\.env|.*\.pem|id_rsa|id_ed25519)$' \
&& { echo "sensitive file in outgoing commits"; exit 2; }
# content: catch credential-shaped strings in the added lines
patterns='(AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|sk-[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----)'
git diff "$range" | grep -qE "^\+.*$patterns" \
&& { echo "credential-shaped string in outgoing diff"; exit 2; }
Wire that into a Claude Code PreToolUse hook matched on git push,
and use gitleaks as the primary
scanner with the regex layer as a portable fallback. The hook exits 2 to block, and
the agent gets told why — so it stops instead of retrying.
Treat it as compromised the instant it reaches a remote, even a private one.
Rotate the credential first; rewriting history (git filter-repo) is
cleanup, not containment. This is exactly why a pre-push gate is worth the five
minutes to set up — recovery is always more expensive.