A transparent git security shim. It installs ahead of the real git on your
PATH, inspects every git command for git-native code-execution traps, hard-blocks
the dangerous ones before git runs, warns on the suspicious ones, and is otherwise
completely invisible — same output, same exit codes, negligible overhead.
$ git clone https://github.com/someone/cool-tool
Cloning into 'cool-tool'...
[BLOCK] active-hook
cool-tool/.git/hooks/post-checkout
value: post-checkout
why: an installed git hook runs automatically on git operations
git-wrapper: cloned repo "cool-tool" contains BLOCK-level issues. Do not run commands inside it.
There is a steady stream of malware distributed through GitHub: fake "cracked" software, typosquatted packages, trojanized proof-of-concept exploits, and ordinary looking repositories that attack you the moment you interact with them. A common and under-appreciated class of these attacks doesn't need you to run the project at all — it abuses git itself.
The important and counter-intuitive fact: git clone does not execute a remote
repo's hooks. Hooks live in .git/hooks/ and are never transmitted on clone. So the
danger isn't usually the clone — it's the moments right after:
- Poisoned
.git/config. A repo delivered as an archive (zip/tarball) or a synced folder can carry its own.git/config. Keys likecore.fsmonitor,core.hooksPath, andcore.sshCommandname a command that git runs on your very next git operation — evengit status, even your editor silently runninggitin the background. This is remote code execution triggered by looking at the repo. - Active hooks that arrive via an archive, or get wired up by a hook-manager
package (husky, pre-commit) during
npm install/pip install. - Install-time scripts —
postinstallinpackage.json, customcmdclassinsetup.py— that run automatically during dependency installation. - Committed binaries and obfuscated payloads smuggled into a "source" repo.
git-wrapper is positioned exactly where these fire. Because it sits in front of every
git invocation, it can read .git/config before handing control to git — closing
the core.fsmonitor "next command" window that a one-time scanner would miss.
- It catches the attacks you can't see coming. The config-execution vector runs before you've typed anything dangerous. A shim is the only place you can intercept it.
- Zero workflow change. It is
git. Commands you already run behave identically; there's no new tool to remember and no flags to learn for everyday use. - Fails open, never locks you out. If the wrapper hits any internal error — or even panics — it logs and falls through to real git. A bug in the shim can never break your git. (This is an enforced, tested invariant.)
- Strict but escapable. Dangerous findings block by default, but a single
--forceoverrides and remembers your decision so you're not nagged again. - Tiny and dependency-free. One ~3–4 MB static Go binary, standard library only, ~2 ms overhead per call. Nothing to trust but the source in this repo.
Who benefits most: anyone who clones, reviews, or tries out code they didn't write — security researchers, open-source maintainers triaging PRs, developers grabbing tools and dependencies, students, CTF players. It is defense in depth, not a silver bullet: the strongest habit is still to run first-time/untrusted installs in a disposable VM. git-wrapper backs that up by catching the git-layer traps automatically, every time.
git-wrapper is a single binary named git, placed in a directory earlier on your PATH
than the system git. On each call it:
- Resolves the real git once (the next
gitonPATHthat isn't itself). - Cheap config gate — every command. If you're inside a repo, it reads only
.git/config(sub-millisecond) and blocks if it finds an execution vector. This is the primarycore.fsmonitordefense, and it correctly follows thegitdir:file used by submodules and linked worktrees so those can't slip past. - Full scan — content-acquiring commands only. For commands that pull new tree
content (see the list under Configuration), it
runs the hooks / install-script / binary / obfuscation checks.
cloneis scanned after it runs (the repo doesn't exist until then); everything else is scanned before. Results are fingerprint-cached so an unchanged repo isn't rescanned. - Hands off to real git, byte-for-byte: your args, stdin/stdout/stderr, and exit code are passed through unchanged.
Two invariants are enforced and tested: fail-open (any wrapper error → real git
still runs) and transparent passthrough (non-scanned commands are indistinguishable
from bare git, and --force is never stripped from the args git receives).
| Check | What it looks for | Severity |
|---|---|---|
| Dangerous git config | core.fsmonitor, core.hooksPath, core.sshCommand, !-prefixed core.pager/core.editor, !-prefixed aliases, url.*.insteadOf redirects |
BLOCK |
| Active hooks | non-.sample files in .git/hooks/ |
BLOCK |
| Checked-in hooks | .githooks/, .husky/, .pre-commit-config.yaml, READMEs instructing core.hooksPath |
WARN |
| Install scripts | package.json preinstall/install/postinstall/prepare, setup.py cmdclass, `curl … |
sh` patterns |
| Binaries & obfuscation | committed .exe/.dll/.so/.dylib/.bin/.msi, Discord/Telegram webhook URLs, eval(atob()), long base64 blobs |
WARN |
Every finding prints its severity, the file:line, the offending value, and a one-line
explanation of why it's dangerous.
- BLOCK stops before real git runs. You must neutralize the issue or
--force. - WARN prints but does not stop — it's there to inform, e.g. "remember to run
npm install --ignore-scriptshere."
Requires Go 1.26+ and macOS or Linux.
git clone git@github.com:jbrahy/git-wrapper.git
cd git-wrapper
sh install.shinstall.sh builds the binary into ~/.local/bin/git-wrapper/git and prints the line to
add to your shell profile. Put that directory at the front of your PATH:
# ~/.zshrc or ~/.bashrc
export PATH="$HOME/.local/bin/git-wrapper:$PATH"Verify the shim is active:
command -v git # should print …/.local/bin/git-wrapper/git
git --version # should print the normal "git version …"To install elsewhere, set PREFIX (see Configuration).
git-wrapper is intentionally low-config: it works out of the box and is tuned through a small set of environment variables, one flag, and one state file. There is no config file to write in v1 (per-rule severity tuning is planned for v2 — see Limitations).
| Variable | Used by | Default | Effect |
|---|---|---|---|
GIT_WRAPPER_DISABLE |
runtime | unset | Set to 1 to fully bypass scanning for that invocation — pure passthrough to real git. The global escape hatch for scripts, CI, or a command the wrapper gets in the way of: GIT_WRAPPER_DISABLE=1 git status. |
XDG_CONFIG_HOME |
runtime | $HOME/.config |
Base directory for git-wrapper's state. The allowlist lives at $XDG_CONFIG_HOME/git-wrapper/allowlist.json. |
HOME |
runtime | — | Used to locate the config dir when XDG_CONFIG_HOME is unset. |
PATH |
runtime | — | Must list the wrapper's directory before the real git's. This is what makes the shim active; see Install. |
PREFIX |
install.sh |
$HOME/.local/bin/git-wrapper |
Where install.sh builds and places the git binary, e.g. PREFIX=/usr/local/libexec/git-wrapper sh install.sh. |
When a command is BLOCKed, re-run it with --force (or -f) to override:
git fetch --force # runs despite a BLOCK, and remembers this repo--force is detected by the wrapper but not stripped — it is still passed to
real git. This means it's intended for commands that already accept or ignore a trailing
--force (clone, fetch, pull, checkout, push). For a command where --force isn't valid
(e.g. git status), use GIT_WRAPPER_DISABLE=1 instead.
The first --force on a repo records it so you aren't re-prompted on every subsequent
command. Approved repos run completely silently (no findings printed) until their
content changes.
-
Location:
$XDG_CONFIG_HOME/git-wrapper/allowlist.json(default~/.config/git-wrapper/allowlist.json). -
Format: a JSON object mapping each repo's absolute path to a content fingerprint:
{ "/Users/you/src/some-repo": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" } -
The fingerprint is content-based — a SHA-256 over the repo path plus the bytes of
.git/HEAD,.git/config, and the sorted hook filenames. If any of those change (i.e., the repo is re-poisoned), the fingerprint no longer matches and the warnings return automatically. It deliberately does not use file mtimes, which an attacker could forge. -
To revoke an approval: delete that repo's line (or the whole file). The next command re-scans from scratch.
-
To pre-approve or audit in bulk: the file is plain JSON — edit it directly.
The cheap .git/config gate runs on every command. The heavier hooks/install/binary
scan runs only on commands that can bring new tree content into your working copy:
clone fetch pull checkout merge switch submodule
restore reset stash worktree am apply
Everything else (status, log, commit, diff, …) skips the full scan entirely.
v1 keeps the rule set and severities fixed in the binary. The levers you have today:
- Approve a repo you trust with one
--force→ it goes silent. - Bypass entirely for a command or a whole script with
GIT_WRAPPER_DISABLE=1.
Per-rule enable/disable and severity overrides via a config file are the first item on the v2 roadmap.
Threat model. git-wrapper defends against repository-supplied git-native execution
(malicious config, hooks, install scripts) and flags suspicious committed content. It
assumes you control your own PATH and binaries. If an attacker can already place a fake
git ahead of the wrapper on your PATH, that's outside the model — they already have
code execution.
It is a layer, not a sandbox. It detects and blocks known git-layer traps; it does
not contain code you choose to run. For genuinely untrusted projects, still do
first-time installs in a disposable VM with no real SSH keys, .env, browser profiles,
or wallets present.
- macOS and Linux only. Windows PATH-shim mechanics differ and are deferred.
- No repo-reputation checks yet (stars/age/typosquatting). Deferred to v2 — needs network access and a GitHub token.
- WARN checks are heuristic and can have false positives (e.g. a legitimate minified asset that looks like a base64 blob). They never block, only inform.
- No per-rule config file yet (v2).
- Submodule tree recursion depth for the full scan is limited in v1 (the config
gate already follows submodule/worktree
gitdir:files).
Small, focused Go packages, standard library only:
cmd/git/ # entrypoint: resolve real git, gate, scan, exec — fails open
internal/resolve/ # find the real git; resolve repo root + (possibly file-based) gitdir
internal/scan/ # one file per check (config, hooks, install, binary) + a registry
internal/report/ # Finding + Severity (BLOCK/WARN) formatting
internal/state/ # --force allowlist + content fingerprint
install.sh # build onto PATH and print setup instructions
Run the test suite:
go test ./...
go vet ./...The design rationale and the task-by-task implementation plan are committed under
docs/superpowers/ if you want to see how and why it was built.
Issues and PRs welcome. Please keep the two invariants intact (fail-open and transparent
passthrough), add tests for any new check, and run go test ./... && go vet ./... before
opening a PR.
MIT — a permissive license. You may use, copy, modify, merge, publish, distribute, sublicense, and sell copies, including in closed-source and commercial products, with no obligation beyond preserving the copyright notice. In short: everyone is free to do what they want with it.