Developer security // Git guardrails
Git Hooks Tips & Tricks: Stop AI Hallucinations and Secret Leaks Before You Commit
Three small hooks can catch leaked keys, invented dependencies, and broken builds in the awkward second between “looks fine” and push.
00 // The dangerous three seconds
AI writes quickly. Humans review optimistically.
A coding assistant can produce four hundred convincing lines before you finish your coffee. The awkward part is that “convincing” is not a compiler guarantee. It may paste an environment value into a test, import a package that does not exist, leave a stub behind, or change a build file while solving an unrelated problem. Then muscle memory takes over: stage, commit, push.
Git hooks put executable checkpoints inside that reflex. Git runs
pre-commit before creating a commit and
pre-push before transmitting refs. Exit with a nonzero
status and the operation stops. That makes hooks ideal for fast,
local feedback—not magical security, but a useful tripwire close to
the mistake.
This matters more in an AI-assisted workflow because generated code arrives without the small pauses that normally build understanding. When you write an import yourself, you probably remember whether you installed its package. When an assistant edits six files at once, the diff can look internally consistent while depending on a nonexistent API. Hooks convert a few high-confidence expectations into machine-enforced friction: credentials should not enter history, changed source should satisfy local quality rules, and manifests should still describe a buildable project.
Tip 01 // The AI API key sniffer
Scan what is staged—not whatever happens to be on disk
A naive hook greps the working directory. That produces the wrong
answer when a file has staged and unstaged versions. The index is
what the commit will contain, so the hook below lists staged paths
with NUL separators and reads each staged blob through
git show ":$path". It never prints the matching value,
because turning the terminal log into a second secret leak would be
an impressively self-defeating feature.
#!/usr/bin/env bash
set -euo pipefail
PATTERN='(sk-(proj-)?[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|A(KIA|SIA)[A-Z0-9]{16}|(api[_-]?key|secret|token)[[:space:]]*[:=][[:space:]]*[A-Za-z0-9_./+=-]{32,}|-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----)'
found=0
while IFS= read -r -d '' path; do
# Read the index version. -I treats binary input as non-matching.
if git show ":$path" 2>/dev/null |
LC_ALL=C grep -IqE "$PATTERN"; then
printf 'ERROR: potential secret in staged file: %s\n' "$path" >&2
found=1
fi
done < <(git diff --cached --name-only --diff-filter=ACMR -z)
if (( found )); then
printf 'Commit blocked. Remove the value and rotate it if exposed.\n' >&2
exit 1
fi
Save it, then run chmod +x .git/hooks/pre-commit.
The expressions intentionally favor interruption over perfect
classification. Provider formats evolve, high-entropy generic keys
are hard to identify reliably, and false positives happen.
Production teams should supplement this tripwire with a maintained
scanner such as Gitleaks or TruffleHog and GitHub secret scanning.
If a real key ever reached a commit or remote, deletion is not
remediation: revoke or rotate it.
The scope is deliberately narrow. It checks files participating in this commit, not every historical object and not untracked files you forgot to add. That keeps normal commits quick, but it means the hook cannot certify that the repository has always been clean. Perform a full repository and history scan during onboarding and in scheduled CI. Also treat pattern matches as signals rather than proof: an example token in documentation can resemble a credential, while a provider-specific secret may evade a stale expression. Prefer a documented suppression tied to a fake fixture over weakening the pattern for everybody.
Tip 02 // The hallucination validator
Spend the expensive checks only where the push changed
Pre-push is a better home for linting and compilation because it
runs less often than pre-commit. Git supplies each local and remote
object ID on standard input. This hook builds a changed-file list,
lints only changed JavaScript and TypeScript files with the
repository’s already-installed ESLint, and triggers
cargo check --locked when Rust changed.
#!/usr/bin/env bash
set -euo pipefail
remote_name="${1:-origin}"
remote_head="$(git symbolic-ref -q \
"refs/remotes/$remote_name/HEAD" 2>/dev/null || true)"
changed=()
while read -r local_ref local_oid remote_ref remote_oid; do
[[ "$local_oid" =~ ^0+$ ]] && continue # deleted ref
if git cat-file -e "$remote_oid^{commit}" 2>/dev/null; then
base="$remote_oid"
elif [[ -n "$remote_head" ]]; then
base="$(git merge-base "$local_oid" "$remote_head")"
else
base="$(git hash-object -t tree /dev/null)"
fi
while IFS= read -r -d '' file; do
changed+=("$file")
done < <(git diff --name-only --diff-filter=ACMR \
-z "$base" "$local_oid")
done
js_files=()
rust_changed=0
for file in "${changed[@]}"; do
case "$file" in
*.js|*.jsx|*.mjs|*.cjs|*.ts|*.tsx) js_files+=("$file") ;;
*.rs|Cargo.toml|Cargo.lock) rust_changed=1 ;;
esac
done
if ((${#js_files[@]})); then
[[ -x node_modules/.bin/eslint ]] || {
echo "ESLint missing. Run npm ci first." >&2; exit 1;
}
node_modules/.bin/eslint --max-warnings=0 "${js_files[@]}"
fi
if ((rust_changed)); then
cargo check --locked
fi
echo "Pre-push validation passed."
The “already-installed” detail matters. Calling an unrestricted
package runner can fetch a similarly named package while you are
trying to detect hallucinated dependencies. Pin tools in the
project lockfile and fail closed when they are missing. For a
containerized project, add a conditional such as
docker build --pull=false -t local/prepush-check .
when Dockerfile, a lockfile, or deployment code changes.
Keep the hook under a minute; leave full matrices, integration
tests, and networked builds to CI.
Add type checks when they provide information the linter cannot:
npm run typecheck --if-present is a useful project-level
companion after changed-file linting. Be careful with the phrase
“only changed files,” though. ESLint can inspect an isolated file;
a Rust compiler, TypeScript project reference, or bundler reasons
about a dependency graph. Trigger those broader checks only when
relevant inputs changed, but let the tool inspect the graph it
needs. Artificially forcing every validator to one-file scope can
make the hook fast by making it wrong.
Tip 03 // The lazy-developer automation
Make every future repository inherit the guardrails
Hooks stored in .git/hooks are local metadata, not
normal tracked files. That is useful for personal customization and
terrible for remembering setup. Git’s template directory solves the
next-repository problem: its contents are copied into a new
repository’s Git directory during initialization.
mkdir -p "$HOME/.git-templates/hooks"
cp ./pre-commit "$HOME/.git-templates/hooks/pre-commit"
cp ./pre-push "$HOME/.git-templates/hooks/pre-push"
chmod +x "$HOME/.git-templates/hooks/"*
git config --global init.templateDir "$HOME/.git-templates"
New repositories now receive executable copies. A template is a
stamp, not a live link: editing the template later does not update
repositories already created. Install into those explicitly, or
rerun git init to copy template files that are not
already present. If you truly want one centrally updated hook set,
configure core.hooksPath globally instead. That is
powerful, but it also replaces repository-specific hook discovery,
so a universal dispatcher is safer than one enormous script that
assumes every repository uses Node.
There is one more portability trap: executable bits and shell
availability. The examples require Bash because they use arrays and
process substitution. They work naturally on Linux, macOS, and Git
Bash, but a team with PowerShell-only Windows environments should
ship an equivalent wrapper rather than assuming
/usr/bin/env bash exists. Test installation on every
supported developer platform, and make the installer verify both
the executable permission and a harmless dry run. A beautiful hook
that Git silently ignores is just documentation with ambitions.
04 // Know where the guardrail ends
A local hook protects attention, not policy
Fast locally
Secret patterns and formatting checks belong before commit. Compilation belongs before push. Slow, flaky checks train developers to bypass everything.
Version the source
Keep reviewed hook sources in .githooks/ and provide an installer. Never ask developers to execute an AI-generated hook they have not read.
Enforce remotely
Git allows hooks to be bypassed, and a compromised workstation can modify them. Repeat critical checks in protected CI and enable server-side push protection.
The best setup is layered: tiny deterministic checks on the laptop, maintained scanners and complete tests in CI, and repository rules that prevent unreviewed failures from merging. The hook’s real job is to make the safest action the easiest action while the developer still remembers what the AI changed.
05 // Turn snippets into a team contract
Choose each checkpoint by cost and consequence
The scripts become durable when the team agrees what belongs at each boundary. Pre-commit should answer questions that are cheap, deterministic, and directly related to the staged snapshot: secret patterns, forbidden files, formatting, and generated-file drift. Pre-push can afford dependency-aware linting, type checking, compilation, and a quick container build. CI owns the expensive matrix: clean-environment installation, integration tests, full secret scanners, license checks, and multiple runtimes.
| Boundary | Good checks | Target |
|---|---|---|
| Pre-commit | Staged secrets, format, forbidden files | Seconds |
| Pre-push | Changed-file lint, types, compile, smoke build | Under a minute |
| Protected CI | Clean install, full tests, maintained scanners | Authoritative |
Pin tool versions, keep hook source under review, and print a clear
repair command when blocking work. “Validation failed” creates a
scavenger hunt; “ESLint is missing—run npm ci” creates a next step.
Record bypasses in the pull request when an emergency genuinely
requires one. A bypass that is visible, explained, and rechecked by
CI is an exception. An invisible --no-verify habit is
the collapse of the control.
Finally, design for failure without designing for surrender. If a network service is unavailable, local checks should not hang. If a scanner crashes, sensitive repositories should fail closed and say why. If the repository is an experimental sandbox, a warning may be appropriate. The policy depends on risk; the important part is that the decision is explicit, reviewed, and consistent across human- and AI-authored changes.
Sources // primary documentation