Require a Touch ID confirmation before git push reaches the remotes you care about — and make the
prompt say exactly what is being pushed.
Every other remote passes through silently. Fetches, pulls and pushes to your own forks are untouched.
If you hold commit rights to a shared upstream organisation, that access is normally ambient: any process running as you — a script, an IDE, a misconfigured CI helper, an AI coding agent — can push to it with no interaction at all. Most of the time that is convenient. Occasionally it is how an unintended commit lands on a branch that many people depend on.
git-push-gate puts one deliberate human action in front of those pushes, and only those.
Both exist, both work, and both give you an anonymous prompt:
| Approach | Prompt text |
|---|---|
sudo + /etc/pam.d/sudo_local → pam_tid.so |
Fixed: "sudo is trying to…". Also needs root to set up, grants root as a side effect, and the sudo timestamp cache means the second push in five minutes isn't gated at all unless you also set timestamp_timeout=0. |
security authorize -u <right> |
Fixed, drawn from the right's description in the authorization policy database. Cannot vary per call. |
LAContext.localizedReason |
Whatever you pass in — repo, branch, commit count, sha range. |
That difference is the whole point. A generic "Authentication required" trains you to approve reflexively; after a week you are touching the sensor without reading. A prompt that names the repo, the branch and the commit range is the difference between a confirmation and actual consent — it lets you notice that you are about to push a branch you have never heard of.
There is no Homebrew formula that does this. pamtester is not in homebrew-core, and pam-reattach
is a PAM module for reattaching to the GUI session under tmux, not a prompter. Hence ~40 lines of Swift.
- macOS with Touch ID
- Xcode command line tools (for
swiftcandcodesign) —xcode-select --install
git clone https://github.com/cominvent/git-push-gate.git
cd git-push-gate
./install.shOr by hand:
swiftc -O -o ~/.local/bin/git-push-gate git-push-gate.swift
codesign -s - --force ~/.local/bin/git-push-gate
mkdir -p ~/.config/git/hooks
cp hooks/pre-push ~/.config/git/hooks/pre-push
chmod +x ~/.config/git/hooks/pre-pushThe ad-hoc signing step is not optional. An unsigned command line binary can be refused the
biometric prompt outright — you get a silent failure rather than a dialog. codesign -s - is enough;
you do not need a developer certificate.
Then enable it globally:
git config --global core.hooksPath ~/.config/git/hooks
git config --global pushgate.pattern 'github\.com[:/](myorg|otherorg)/'| Key | Meaning |
|---|---|
pushgate.pattern |
Case-insensitive extended regex matched against the remote URL. If it matches, the push is gated. There is no default — until you set this, nothing is gated and the hook prints a one-line reminder on each push. |
PUSHGATE_HELPER (env) |
Override the path to the helper binary. Defaults to $HOME/.local/bin/git-push-gate. |
The pattern is matched against the URL, not the remote name, so it covers both forms:
git@github.com:myorg/repo.git
https://github.com/myorg/repo.git
…including an explicit git push git@github.com:myorg/repo.git main with no configured remote at all.
Match several orgs with alternation, and be careful to anchor the trailing slash so that myorg-sandbox/
does not match myorg/:
git config --global pushgate.pattern 'github\.com[:/](myorg|myorg-infra)/'Invoke the hook directly with a synthetic ref list. This exercises the real matching and prompt logic without touching the network:
SHA=$(git rev-parse HEAD)
OLD=$(git rev-parse HEAD~3)
# should prompt
printf 'refs/heads/main %s refs/heads/main %s\n' "$SHA" "$OLD" \
| ~/.config/git/hooks/pre-push upstream git@github.com:myorg/repo.git
echo "exit=$?"
# should be silent, exit 0
printf 'refs/heads/main %s refs/heads/main %s\n' "$SHA" "$OLD" \
| ~/.config/git/hooks/pre-push origin git@github.com:me/repo.git
echo "exit=$?"Do not test by pushing to a repository that does not exist. It is the obvious "safe" experiment and
it produces a false negative: git performs remote ref discovery before running pre-push, so a
missing repository fails during discovery and the hook never fires at all. No prompt appears, and you
conclude the gate is broken — or worse, that it is working when you have tested nothing.
git push --dry-run does run the hook (verified), so that is a safe end-to-end check against a
remote that really exists.
pre-push receives the remote name as $1, the remote URL as $2, and the list of refs being pushed
on stdin as <local-ref> <local-sha> <remote-ref> <remote-sha>. The hook:
- Matches
$2againstpushgate.pattern; on no match it exits 0 immediately. - Reads stdin once, then derives a human summary — commit count, branch, abbreviated sha range, handling new branches and branch deletions.
- Calls the helper with that summary as the dialog text. Non-zero exit aborts the push.
- Chains to a repo-local
.git/hooks/pre-pushif one exists and is executable, passing the same arguments and replaying stdin.
The helper prefers biometrics and falls back to password authentication when no sensor is available, so a clamshell or external-keyboard session degrades instead of locking you out. Any error path exits non-zero: it fails closed.
Exit codes: 0 authenticated, 1 cancelled or failed, 2 no authentication mechanism available.
Be clear-eyed about what this is. It is a deliberate-consent gate against accident and automation, not a security boundary against someone who already controls your shell.
git push --no-verifyskips all pre-push hooks. There is no way for a hook to prevent that.- Anything that does not go through
git pushis unaffected — IDE integrations pushing via their own stored tokens, REST/GraphQL API calls, web-UI merges. - Anyone who can edit your git config or the hook file can disable it.
core.hooksPathis global and overrides every repository's.git/hooks. That is why this hook chains to a repo-localpre-pushif one exists — but be aware of the interaction if you use tooling that installs hooks per-repository, such as Husky or pre-commit.- It gates pushes only. Fetching, cloning and reading are never prompted.
If you want a guarantee at the key level rather than the workflow level, look at storing your SSH key in the Secure Enclave instead — for example with Secretive, where the private key is unextractable and every SSH operation requires a touch. That is a stronger property, at the cost of a prompt on every fetch as well, and it cannot tell one organisation's remote from another's.
Apache License 2.0. See LICENSE.