hookguard

Author, maintainer · Since Jul 2026

A trust-first git hook manager in Rust. Committed hooks install themselves the moment you clone a repo, but nothing executes until you accept the exact commands, and consent is keyed to their content so upstream can't swap them under you.

Git hooks are code someone else wrote that runs on your machine during ordinary git operations. Every hook manager I tried treats “hooks install automatically” and “you trust the repo” as one decision: opt in, and the cloned config runs. I wanted those split. hookguard makes install automatic and execution consent-gated, and it keys consent to the content of everything executable, so an upstream that changes what its hooks do always has to ask you again.

The problem

The two things I want from a hook manager pull in opposite directions, and no existing tool gives me both.

Automatic means a teammate clones the repo and the committed hooks are already wired up, with zero setup on their end. husky gets there through an npm postinstall script, so it needs Node and a package.json lifecycle hook. pre-commit needs Python, and it refused auto-install-on-clone rather than solve the consent question. lefthook is a native Go binary and fast, but it has no trust model: the cloned config runs the moment you opt in.

Security-oriented means nothing runs on my machine that I did not agree to run. Every tool above treats the clone, or the opt-in, as blanket permission. So you either get automatic-and-trusting or manual-and-safe. I did not find a native Rust option that is both, so I wrote one.

The approach

hookguard separates the automatic part from the trust part, and binds trust to content instead of to the act of cloning.

Hooks are committed to the repo in .githooks.toml, with any referenced scripts under .githooks/. Running git hooks install once per machine sets git’s init.templateDir, so every future clone or init lands a shim in .git/hooks/. The shim execs git-hooks run <hook>, which is a gate, not a runner. Nothing has consented to anything yet.

The first time a hook would fire, the gate prompts on /dev/tty with the exact commands about to run, and executes nothing until you accept. Your answer is stored 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/. Change one byte upstream and the hash changes, so you get prompted again, this time with a git diff against what you accepted rather than a wall of config.

Design decisions

  • Consent is keyed to content, not to a repo. The stored decision is a hash of the toml plus the .githooks/ tree. There is no “trust once, run forever” window: an upstream that alters what its hooks do always re-prompts.
  • No terminal means no run. With no /dev/tty to ask on (CI, cron, scripts), consent defaults to off and hooks are skipped. Safe by default, never a silent yes. For reproducible pipelines, GIT_HOOKS_CONSENT=accept:<hash> opts in only when the content still matches.
  • Signed trust moves the boundary from content to key. A maintainer runs git hooks sign --key; verification is ssh-keygen -Y verify pinned to the git-hooks namespace. A cloner who runs git hooks trust <fingerprint> auto-accepts future signed changes, silently, killing prompt fatigue without dropping the boundary. Trust lives repo-local or org-wide in ~/.config/git-hooks/policy.toml.
  • Staged-file awareness, because it’s the one lefthook feature people use. A command written as { run, glob } gets {staged_files} substitution and per-command glob filtering, and is skipped when nothing staged matches.
  • One file, one dependency, auditable in a sitting. Around 1,320 lines of Rust in a single file, with the toml crate as the only dependency. The e2e suite is one 734-line file with no dev dependencies, isolating HOME per test and using setsid to drop the controlling terminal.

Consent is a snapshot of specific bytes, not a sandbox. It does not cover runtime-referenced repo files outside .githooks/, the $PATH binaries a command names, or what the network hands back. The repo’s SECURITY.md is the honest account of that boundary.

Install

cargo install hookguard              # compile from source
cargo binstall hookguard             # prebuilt binary, no compile
nix run github:itaywol/hookguard     # flake, ad hoc

The binary is called git-hooks, so it plugs in as git hooks <command>. Static musl binaries for Linux and native builds for macOS and Windows ship on the releases page. Licensed MIT OR Apache-2.0. hookguard supports 14 client-side hooks and works on Windows via CONIN$/CONOUT$ in place of /dev/tty.