Cloning a repo runs code, and your hook manager is fine with that
Git hooks are code the repository author wrote that runs on your machine during ordinary git operations: commit, checkout, push. Most hook managers copy the cloned config and run it, so cloning becomes an implicit “execute arbitrary code” step you never explicitly agreed to. I built hookguard to close that hole without giving up the convenience that makes hooks worth using. This is the problem it solves and how the trust model works.
Two things nobody gives you together
Every hook manager forces a choice between automatic and safe, and I wanted both.
Automatic means a teammate clones the repo and the hooks are already wired up,
with nothing to install by hand. That matters because the alternative is the
oldest failure mode in the category: someone forgets to run the setup step, the
pre-commit checks never fire for them, and the formatting job on CI catches what
a local hook should have. husky gets automatic through an npm postinstall
script, so it needs Node and a package.json. pre-commit needs Python, and it
refused auto-install-on-clone
outright. lefthook is a native Go binary, which I respect, but its trust model
is the same as everyone else’s: none.
Safe means nothing executes on my machine that I did not agree to run. Here is
where every tool collapses the two decisions into one. Opting in, or cloning,
becomes blanket permission for whatever commands the repo ships now and whatever
it ships after the next git pull. “Hooks run automatically” and “you trust
this repository” get treated as the same yes. I wanted them to be two.
Install is automatic, execution is gated
hookguard splits the decision. You run git hooks install once per machine,
which sets git’s init.templateDir so every future clone drops a shim into
.git/hooks/. The shim does not run your commands. It execs git-hooks run <hook>, which is a gate.
The hooks themselves are committed to the repo in .githooks.toml:
[hooks]
pre-commit = [
"cargo fmt --check",
{ run = "rustfmt --check {staged_files}", glob = "*.rs" },
".githooks/check.sh",
]
Each entry is a bare command run through sh -c, or a table that adds
staged-file awareness. {staged_files} expands to the staged file list, and an
optional glob filters it first, skipping the command when nothing matches. Any
command may reference a script committed under .githooks/, and that script’s
content is covered by consent exactly like the toml.
Consent is keyed to content
The first time a hook would fire, the gate prompts on your terminal with the exact commands about to run, and executes nothing until you accept:
this repository wants to run the following hooks (.githooks.toml):
[pre-commit]
cargo fmt --check
cargo clippy -- -D warnings
these commands will run on your machine during git operations.
accept? [y/N]
Your answer lands in .git/config, which is never cloned, keyed to a hash of
everything executable: git hash-object over .githooks.toml plus every file
under .githooks/, walked in sorted order. Because consent is bound to content,
there is no “trust once, run forever” window. An upstream that changes one byte
of what its hooks do produces a different hash and prompts you again. The
re-prompt shows a git diff of each changed file against what you accepted, not
the whole config dumped at you.
On a machine with no terminal, there is nothing to prompt on, so the answer is
always no. CI, cron, and scripts get a safe-off no-op. For a pipeline that
should run the hooks, GIT_HOOKS_CONSENT=accept:<hash> opts in only while the
content still matches that hash. The moment the committed hooks change, the pin
stops matching and the run falls back to skipping them, which is the direction
you want CI to fail.
Signing kills prompt fatigue without reopening the hole
Content-keyed consent is sound and noisy. A maintainer who legitimately updates
the hooks re-prompts every teammate, every time, and prompt fatigue trains
people to hit y without reading. Signed trust removes the noise by moving what
you trust from the content to a key.
A maintainer signs with git hooks sign --key ~/.ssh/id_ed25519, and
verification runs ssh-keygen -Y verify pinned to the git-hooks signature
namespace, so a signature made for some other purpose can never be replayed
here. A cloner who runs git hooks trust <fingerprint>, repo-local or org-wide
in ~/.config/git-hooks/policy.toml, gets silent auto-accept of future signed
changes. An untrusted signature still prompts; an invalid one screams. You stop
vouching for bytes and start vouching for a key, once.
What consent does not cover
hookguard’s whole pitch is that it does not hand-wave the boundary, so here is the honest limit. Consent is a snapshot of specific bytes, not a sandbox.
- Runtime-referenced repo files. An inline command can invoke any file in
the working tree at run time:
./scripts/deploy.sh,make, a checked-in binary. Those files are not in the consent hash, so their contents can change without re-prompting. Put anything you want covered under.githooks/. The prompt warns when a command looks like it references a repo path outside.githooks/, but that heuristic has false negatives. - Programs on
$PATH. Consent covers the textcargo clippy, not whatevercargoresolves to on your machine. - The network. A hook can download and run remote content. Consent covers the command that reaches out, never what comes back.
- Everything after you accept. Accepted hooks run with your full user privileges. There is no sandbox behind the yes.
And signed trust concentrates that risk into one place: a compromised signing
key is game over. Whoever holds it can sign arbitrary hooks that every trusting
machine auto-accepts and runs. Trust is the boundary, so git hooks untrust is
the revocation path the moment a key is suspect. I would rather say that plainly
than pretend a signature is a sandbox.
Why Rust, and why one file
hookguard is around 1,320 lines of Rust in a single file, with the toml crate
as its only dependency. That is deliberate. A tool whose entire value is “safe
to recommend org-wide” has to be auditable in one sitting, and it has to install
the same way on a laptop, in CI, and in a container without dragging a runtime
along. The e2e suite is one 734-line file with no dev dependencies, isolating
HOME per test and using setsid to drop the controlling terminal so the
no-TTY path gets tested for real.
The project page has the design decisions, the
repo has the code and a comparison table
that is fair about where pre-commit and gabyx/Githooks beat it, and
SECURITY.md is the
full trust model. Install with cargo install hookguard, run git hooks install once, and cloning stops being a code-execution step you did not sign up
for.