Skip to content

git hooks

mcrowe edited this page Jul 13, 2026 · 3 revisions

Git hooks (and the secret gate)

Git hooks are where a lot of tooling quietly collides. harnessed adds a secret gate — nothing that looks like a credential should reach a commit, and certainly not a push — and doing that well means understanding how git actually resolves hooks, and why two well-behaved tools (beads and pre-commit) can refuse to share a repo.

This guide covers both halves: the gate on your commits (git hooks, host-side) and the gate on the agent's commits (a harness hook, container-side). They are different mechanisms, and the difference matters.

The two gates

Who it stops Mechanism Bypassable with --no-verify?
Host You, committing from your terminal git hooks (pre-commit + gitleaks) Yes — it is a git hook
Container The agent, calling the Bash tool PreToolUse hook that denies the tool call No — it isn't a git hook

The container gate needs no setup: the mikes-universal-setup recipe wires gitleaks-guard as a PreToolUse hook. When the agent tries git commit or git push, the harness scans first and refuses the tool call outright. --no-verify skips git's hooks, not the harness's — the command never runs. (It also survives on omp: omp-claude-hooks-bridge drops a hook's additionalContext but honors permissionDecision, and a deny is a permissionDecision.)

The host gate is the rest of this guide.

How git actually finds a hook

Three rules, and every trap below follows from them.

1. core.hooksPath wins over .git/hooks. If it is set — locally or globally — git looks there and your repo's own .git/hooks are ignored entirely. Not merged. Ignored. A local value overrides a global one.

2. Worktrees share one hooks dir. Hooks live in the common git dir, not the worktree's .git (which is a file, not a directory). Install a hook from one worktree and every worktree of that repo gets it:

git rev-parse --git-common-dir     # <- hooks live in $(that)/hooks

3. A hook is just an executable. No chaining, no ordering, no plugin system. One file per event. Two tools that both want pre-commit must negotiate, and mostly they don't.

The collisions

pre-commit refuses to install when core.hooksPath is set

[ERROR] Cowardly refusing to install hooks with `core.hooksPath` set.
hint: `git config --unset-all core.hooksPath`

This is correct behavior, not a bug: pre-commit installs into .git/hooks, and a core.hooksPath would silently make that a no-op. Rather than install hooks that never run, it refuses.

bd init (beads) sets a local core.hooksPath=.beads/hooks — so any repo where beads was initialized on the host will hit this. In harnessed, beads is container-only: bd lives in the pod (via the beads recipe), not on your host, and the beads recipes deliberately carry no init: so nothing initializes beads against your working copy. If a host core.hooksPath shows up anyway, something installed beads outside the container — unset it and find out what:

git config --unset core.hooksPath            # repo-local
git config --global --unset-all core.hooksPath   # global

A hook that references a tracked file breaks older branches

Hooks are shared across worktrees (rule 2), but whatever the hook points at — a script, a config — is usually a tracked file that only exists on branches carrying it. Check out an older branch, or a worktree on main from before it landed, and every commit there dies: No .pre-commit-config.yaml file was found, or a bare exec .githooks/… failing with "not found".

Both have the same cure — make the hook a no-op when its target is absent:

[ -x .githooks/gitleaks-precommit ] || exit 0     # plain shim
pre-commit install … --allow-missing-config       # pre-commit's equivalent

pre-commit preserves an existing hook — and other installers fight it

If .git/hooks/pre-commit already exists, pre-commit install renames it to pre-commit.legacy and calls it. That is how it coexists with a hook another tool installed.

It is not symmetric. If you later re-run the other tool's installer, it writes into the file pre-commit now owns — and you end up running that tool twice (once from inside pre-commit's script, once via .legacy). If a tool exposes a "run my hook logic" entrypoint, prefer wiring it as a pre-commit hook instead of letting two installers own one file:

  - repo: local
    hooks:
      - id: some-tool
        entry: some-tool hooks run pre-commit
        language: system
        stages: [pre-commit]
        pass_filenames: false
        always_run: true

One mechanism, one config, committed and shared with the team. No .legacy, no clobbering.

The policy: harnessed ships hook LOGIC, never hook WIRING

This falls straight out of rule 3. The wiring is a contested resource — one file per event, no chaining — so any tool that claims .git/hooks/pre-commit is at war with every other tool that wants it. The scan logic, on the other hand, is contested by nobody: a script that reads staged content and exits non-zero composes with any wiring at all.

So:

A recipe that needs a host git hook ships a script. It never writes .git/hooks, never sets core.hooksPath, and never requires a hook framework. How the script gets wired is the user's choice.

Agent-side gating uses harness hooks (PreToolUse), which harnessed owns end to end and which need no user setup.

Requiring a framework would buy no enforcement anyway: --no-verify bypasses any git hook, plain or framework-managed. Enforcement lives in the container gate, which is not a git hook.

Setting up the host secret gate

mise use -g gitleaks@8.30.1     # or brew install gitleaks

Then wire the two scripts — .githooks/gitleaks-precommit and .githooks/gitleaks-push — however you already work.

Plain git hooks, no framework:

H=$(git rev-parse --git-common-dir)/hooks
printf '#!/bin/sh\n[ -x .githooks/gitleaks-precommit ] || exit 0\nexec .githooks/gitleaks-precommit\n' > "$H/pre-commit"
printf '#!/bin/sh\n[ -x .githooks/gitleaks-push ] || exit 0\nexec .githooks/gitleaks-push "$@"\n'     > "$H/pre-push"
chmod +x "$H/pre-commit" "$H/pre-push"

The [ -x … ] || exit 0 guard is not decoration. Hooks are shared across worktrees (rule 2) but the scripts are tracked files — check out a branch or worktree predating them and an unguarded exec dies with "not found", failing every commit there. (pre-commit has the same hazard and the same cure: --allow-missing-config.)

Already using pre-commit? Point it at the same scripts:

  - repo: local
    hooks:
      - id: gitleaks-precommit
        name: Detect hardcoded secrets (staged)
        entry: .githooks/gitleaks-precommit
        language: script
        stages: [pre-commit]
        pass_filenames: false
        always_run: true
      - id: gitleaks-push
        name: Detect hardcoded secrets (commits being pushed)
        entry: .githooks/gitleaks-push
        language: script
        stages: [pre-push]
        pass_filenames: false
        always_run: true

lefthook / husky / your own dispatcher: point the pre-commit and pre-push steps at the same two paths. Nothing else to do.

The push script works under all of them: it takes the range from PRE_COMMIT_FROM_REF / PRE_COMMIT_TO_REF if pre-commit set them, else from git's stdin ref list (how a plain pre-push hook is told what is being pushed), else from the branch's upstream.

Note gitleaks itself is pinned once, by mise — so the host and the pod agree on the version. A framework that builds its own copy (pre-commit's repo: gitleaks does) gives you a second binary and a second pin to keep in sync.

Traps that make a gate worse than useless

A gate you trust that doesn't work is more dangerous than no gate. Each of these was hit for real while building this one.

id: gitleaks-system fails open

gitleaks ships three hook ids. gitleaks and gitleaks-docker set pass_filenames: false; gitleaks-system does not. So pre-commit appends the staged filenames to the command, and gitleaks git … FILE reads FILE as its repo path argument — scanning it as if it were a repository, finding "0 commits", and exiting 0.

It prints a cheerful Passed on a live credential. It is tempting precisely because it reuses a system binary instead of building its own; do not use it.

Scanning the staged tree is not enough

The gitleaks hook scans staged content. That is the right place to catch a secret first, but it is blind to anything that entered history another way:

  • git commit --no-verify
  • a rebase, cherry-pick, or squash
  • commits pulled from a clone that never installed hooks

Push is the irreversible step — once a secret lands on a remote it is cached, forked, and mirrored beyond recall. So the push gets its own scan of the commits actually being sent, via .githooks/gitleaks-push. Verified: a secret committed with --no-verify sails past pre-commit and is caught at push.

That hook takes its range from PRE_COMMIT_FROM_REF / PRE_COMMIT_TO_REF, which pre-commit sets for pre-push hooks. Deliberate: a raw pre-push hook receives its ref list on stdin, and stdin can only be consumed once — if another chained hook reads it first, yours gets nothing.

A hook with no stages: runs in every stage

Give the staged scan no stages: and pre-commit also runs it at pre-push, where nothing is staged. It dutifully scans 0 bytes and reports Passed — a reassurance that means nothing, attached to the exact operation you most wanted checked. Pin it: stages: [pre-commit].

Fail closed, and say which failure

If gitleaks is missing or errors, refuse the operation. An unscanned commit that looks scanned is the failure mode this whole gate exists to prevent.

But distinguish "found a secret" from "the scanner is broken", or you will teach yourself to ignore the gate. --exit-code 2 makes a leak exit 2, leaving every other non-zero exit meaning "gitleaks itself failed". An early version conflated them: a bad flag made gitleaks die, every clean commit was refused as if it held a secret, and the natural next step would have been to rip the gate out.

Escape hatches

GITLEAKS_SKIP=1 git commit …        # skip the staged scan
GITLEAKS_SKIP=1 git push …          # skip the push scan
git commit --no-verify              # skip ALL git hooks (the push scan still catches it later)
SKIP=gitleaks-push git push …       # if you wired the scripts through pre-commit

--no-verify does not get past the container gate: that one denies the tool call, and is not a git hook.

Debugging

git config --get-all core.hooksPath          # set? then .git/hooks is being ignored
git rev-parse --git-common-dir               # where the hooks actually live (worktrees!)
ls "$(git rev-parse --git-common-dir)/hooks" # what is really installed
pre-commit run --all-files                   # run every hook without committing
pre-commit run gitleaks --hook-stage pre-commit --verbose

If a hook seems not to run at all, check core.hooksPath first — it is the answer surprisingly often, and it fails silently by design.

Clone this wiki locally