-
Notifications
You must be signed in to change notification settings - Fork 0
git hooks
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.
| 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.
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)/hooks3. 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.
[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 # globalHooks 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 equivalentIf .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: trueOne mechanism, one config, committed and shared with the team. No .legacy, no clobbering.
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 setscore.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.
mise use -g gitleaks@8.30.1 # or brew install gitleaksThen 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: truelefthook / 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.
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.
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.
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.
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].
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.
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.
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 --verboseIf a hook seems not to run at all, check core.hooksPath first — it is the answer surprisingly
often, and it fails silently by design.
Start Here
Guides
- Recipe authoring
- Service authoring
- Stacks
- Extending stacks (proposed)
- Recipe catalog
- System prompt & rules (proposed)
- Secrets
- AWS SSO
- Pulumi (host login forwarding)
- Egress & exposing services
- Container filesystem
- Git hooks
- Troubleshooting
- Pin management (harnessed update)
Codebase Map
Planning & Roadmap
- open work: GitHub Issues
Research & Prompts
- research/ (home-folder requirements per harness, browse in-repo)
- prompts/ (reusable prompt templates, browse in-repo)