-
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 .pre-commit-config.yaml is a tracked file that
only exists on branches carrying it. Check out an older branch — or a worktree on main before the
config landed — and every commit dies with:
No .pre-commit-config.yaml file was found
Install with --allow-missing-config so the hook no-ops where there is no config:
pre-commit install --hook-type pre-commit --hook-type pre-push --allow-missing-configIf .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.
mise use -g gitleaks@8.30.1 # or brew install gitleaks
pre-commit install --hook-type pre-commit --hook-type pre-push --allow-missing-config.pre-commit-config.yaml (already in the repo):
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
stages: [pre-commit]
- repo: local
hooks:
- id: gitleaks-push
name: Detect hardcoded secrets in commits being pushed
entry: .githooks/gitleaks-push
language: script
stages: [pre-push]
pass_filenames: false
always_run: trueBump the pin with pre-commit autoupdate.
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.
SKIP=gitleaks git commit … # skip the staged scan
SKIP=gitleaks-push git push … # skip the push scan
git commit --no-verify # skip ALL git hooks (the push gate still catches it)--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)