Cloning a repo runs code, and your hook manager is fine with that

  • rust
  • git
  • security
  • tooling

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.

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.

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 text cargo clippy, not whatever cargo resolves 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.