Security gates in the git hooks#
The shared hooks refuse three kinds of text before it leaves the machine, and report a fourth:
| step | hook | on a hit |
|---|---|---|
private repository names in a public repository (check_private_names.sh) |
commit-msg, pre-push |
block |
credentials, through gitleaks (check_sensitive.sh) |
pre-commit, commit-msg, pre-push |
block |
personal identifiers and PII (check_sensitive.sh) |
pre-commit, commit-msg, pre-push |
block |
| foxguard static analysis of the changed files | pre-push |
warning only |
Order in pre-push: deletions-only and nothing-to-send shortcuts (a ref
already at the remote's commit sends nothing; when git sends no ref lines at
all, as for an up-to-date or rejected push, the commits of HEAD that the
remote does not have are checked instead, and with none the push passes),
private names, credentials,
personal identifiers and PII, foxguard, then hygiene and lint of the changed
files (scripts/push_checks.sh), then the repository's own .githooks/pre-push
(which still gets the ref lines). No tests run on push unless the repository
opts in (Tests on push): make test or
./dev test. A repository hook cannot skip the checks: it runs after them. The
security steps live in scripts/security_hooks.sh; hygiene and lint are listed
in What a push checks.
check_sensitive.sh#
scripts/check_sensitive.sh --range origin/main..HEAD # commits: added lines + messages
scripts/check_sensitive.sh --staged # the index
scripts/check_sensitive.sh --file pr-body.md # a PR or issue body
gh pr view 12 --json body -q .body | scripts/check_sensitive.sh --stdin
scripts/check_sensitive.sh --list-identifiers # what is checked, counts only
Only added text is checked. A commit that deletes a value passes; a push that adds a value in one commit and removes it in the next is still refused, because the push publishes both commits. Rewrite the commit that added it.
A pure rename adds nothing. A merge commit is read as a combined diff: only
lines it adds against every parent (a conflict resolution) count; the rest
came from a parent, which is checked as its own commit. A pushed annotated tag
adds its message (without the signature). A binary file has no lines to read:
it is named as binary file not checked, never skipped silently.
A missing private-name list, gitleaks or checker script refuses the push; it
is never reported as "push allowed". Build the list and
install gitleaks, or let one push through with
SECURITY_GATE_ALLOW_UNCHECKED=1 git push.
A push checks what the target remote does not have: a new branch is compared with that remote's refs only, so a commit already on another remote (a private one) is still checked; a push to a bare URL checks the branch's whole history. Private names are judged against the push target's visibility, not origin's.
| exit | meaning | pre-push |
pre-commit, commit-msg |
|---|---|---|---|
| 0 | nothing found | continue | continue |
| 1 | found | refuse | refuse |
| 2 | could not check (gitleaks missing or failed, commits unreadable, terms file or machine identifiers unreadable, HOME unset, a scan that crashed) |
refuse; SECURITY_GATE_ALLOW_UNCHECKED=1 git push lets one push through, with a warning |
warn; the push checks again |
A check that did not run is not a clean one. In pre-push every "could not
check" refuses: exit 2 from either checker, a checker script missing, commit
messages that could not be read. SECURITY_GATE_ALLOW_UNCHECKED=1
(SENSITIVE_ALLOW_UNCHECKED=1 is the same) is narrower than --no-verify:
every check that can run still runs, and the output says what was skipped.
What it looks for#
- Credentials: gitleaks on the pushed commits (
gitleaks git --log-opts, ordetecton gitleaks 8.18 and older), the staged changes, or the file. A committed.gitleaks.tomlis ignored: the default rules always apply. gitleaks is found onPATH, then in the cachepreflight.shinstalls into (bash scripts/ci_security.sh --install-gitleaks), or atGITLEAKS_BIN. - Personal terms: every line of
${XDG_CONFIG_HOME:-~/.config}/script-helpers/sensitive-terms.txt(SENSITIVE_TERMS_FILEoverrides). One term per line,#comments, mode0600. It is never generated and never committed, so nothing can overwrite it. Put there what identifies you and is not a repository name: addresses, a VPN provider, a street, a family name. - This machine, read at each run: non-loopback IPv4 and IPv6 addresses,
default gateways, MAC addresses, the hostname and FQDN, the home directory.
Loopback and Docker, bridge, veth and virbr interfaces are skipped. The
public IP is checked only when cached:
scripts/check_sensitive.sh --refresh-public-ipfetches it once. A push makes no network call. - PII: email addresses, phone numbers, US social security numbers, payment card numbers (Luhn-checked, known test numbers pass).
A MAC address is matched written with : or -. Terms match whole tokens,
case-insensitive: a term 192.0.2.10 matches
192.0.2.10:8080 but not 192.0.2.100 or 1.192.0.2.10. These pass:
RFC 5737 addresses (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24),
2001:db8::/32, addresses at example.com/.org/.net, .test, .invalid,
noreply addresses including users.noreply.github.com, and the git
identity's own email.
Values are never printed. A term is cited by its line in the terms file, a
machine identifier by its kind, a PII hit masked (jan***om).
Allowing a false positive#
Each allow is printed, so it shows in the output:
| how | scope |
|---|---|
sensitive:allow on the line |
that line (PII and terms) |
.git/sensitive-allow, one value or @domain per line |
that clone (PII only; local, never committed, so cloned content cannot allow itself) |
SENSITIVE_ALLOW="a@example.org,@example.net" |
one run |
gitleaks:allow on the line |
credentials, that line |
.git/gitleaksignore, gitleaks fingerprints |
credentials, that clone (a committed .gitleaksignore is ignored here, so a commit cannot list its own key; preflight.sh's secret scan still reads it) |
--no-inline-allow ignores sensitive:allow. The Claude Code hook uses it:
the agent writes the text it is checked on, so a marker in it is not an
allowance from the user.
foxguard#
foxguard is a static-analysis scanner,
0sec-labs/foxguard. The hook runs it on
the files a push adds or changes, one file per run, and prints every line of
output as [pre-push] WARNING (foxguard): ..., then a framed summary and the
scripts/foxguard_report.sh command that saves the findings as JSON. It never
blocks: its shell taint rules flag correct code. Not installed: the push
installs it and runs it; when that cannot happen, a framed WARNING says the
scan did not run and how to install it, and the push goes on.
It is looked up as FOXGUARD_CMD, then the pinned binary in
~/.cache/script-helpers/foxguard/<version>/, then foxguard on PATH.
Install the pinned release, checked against the SHA-256 in lib/ci_defaults.sh
(CI_DEFAULT_FOXGUARD_VERSION, CI_DEFAULT_FOXGUARD_SHA256_*):
Auditing and cleaning a repository's history#
A push checks what it sends. Some decisions need the whole history instead:
every commit, message, tag and file a repository ever held, on every branch.
scripts/prepare_public.sh audits all of it, builds a cleaned copy, and
re-checks the copy.
It works only on mirror clones in a work directory it creates, never writes to the source, and never pushes. The source repository is left as it is; the cleaned copy goes to a new repository.
# 1. Mirror, archive, audit every ref and every commit
scripts/prepare_public.sh audit --source ~/src/my-repo --work ~/audit/my-repo
# -> report.txt / report.json: each hit with ref, commit, path, line, check,
# and for a name hit the name; "Names found" sums them up with their codes
# 2. Choose what the copy keeps, then build it
scripts/prepare_public.sh plan-example > plan.txt # edit it
scripts/prepare_public.sh clean --work ~/audit/my-repo --plan plan.txt
# -> public.git, and changes.txt: each commit old -> new and what it changed
# (files, message, identity; "parents" when only an earlier commit did)
# 3. Re-run the full audit on the copy; exit 0 only with zero hits
scripts/prepare_public.sh verify --work ~/audit/my-repo
| Check | Tool | Over |
|---|---|---|
| listed repository names | check_private_names.sh --strict-ambiguous |
branch and tag names, added lines, file names, the printable text of binary files, commit and tag messages, identities |
| credentials | gitleaks (through check_sensitive.sh) |
all refs, full history |
| personal terms, machine identifiers, PII | check_sensitive.sh |
the same |
machine names in author and committer emails (@host.local, @localhost) |
this script | identities |
| static analysis (advisory, never a hit) | foxguard | the default branch on audit, every branch of the copy on verify |
The repository's own name is not a hit (--self <name>, by default the
source's directory name). A check that cannot run (no name list, no gitleaks)
makes the result exit 2, never a clean pass. A name your allowlist frees (an
everyday word that is also a repository name) is checked only in the shape of a
repository reference (name.git, "the name repo", cd name, a projects path,
host/owner/name), and a hit is a failure here; the report lists each such
name, so you can search for the plain word by hand. Content the
history does not hold is not checked either, and the result is exit 2: a Git
LFS file (only its pointer is in the history) and a binary file version over
50 MB. Drop those paths in the plan, or check them by hand.
The work directory, the mirror, its archive and the reports are readable by you only (mode 0700/0600). Reports show the line around a listed name; credentials and PII appear masked.
The plan file lists the refs the copy keeps (ref refs/heads/main), paths to
remove from all history (drop), paths to rename (rename old==>new), text
to replace in files and messages (replace name==>CODE, in git filter-repo
syntax) and .mailmap lines for authors (mailmap). Every other ref is left
out. A drop or rename path that is not in the history is refused, since a
mistyped path would leave the file in the copy. A plan that leaves no commit is
refused. Commit and tag signatures are not kept: the rewritten history is
unsigned. clean needs git filter-repo.
After verify passes: create the new repository, protect it, then push
public.git to it. Issues, pull requests and releases are not carried over;
recreate any that matter from reviewed text. To review history findings in an
editor, the leak-lock VS Code
extension shows secrets across the whole history.
Turning it on#
In one repository (the default)#
bash <script-helpers>/scripts/setup-hooks.sh # git hooks
bash <script-helpers>/scripts/setup-hooks.sh --claude-repo # Claude Code hook, this repository only
bash <script-helpers>/scripts/setup-hooks.sh --uninstall # undo the git hooks (add --claude-repo for the Claude hook)
Only untracked files change: keys in .git/config, .claude/settings.local.json
(listed in .git/info/exclude), and the hook copies outside the repository.
git status is the same before and after, and --uninstall restores the old
values. A tool that sets up many repositories (git-lantern) runs these per
repository.
Hooks that already run here keep running:
| Before setup | After setup |
|---|---|
no hooks, and the hooks bundled with the setup-hooks.sh that runs |
core.hooksPath points straight at them |
committed .githooks/, or another (older) vendored script-helpers |
chained: the current checks first, then those hooks as the target. A clone set up directly at them before reports unscanned=yes; --repair chains it |
executable hooks in .git/hooks |
chained |
a local core.hooksPath from another tool (husky, an employer's tooling) |
saved as script-helpers.previous-hooksPath, chained |
a global core.hooksPath (an employer's, or --global) |
chained; the global value is not touched |
Chained means core.hooksPath points at a per-repository copy of the wrappers
in <data>/script-helpers/repo-hooks, one for every git hook name, and each
runs, in order, stopping at the first failure:
- the security checks, in your own repositories only (your GitHub account and
the organisations in
script-helpers.owners); - this repository's committed hooks (
script-helpers.hooks-target); - what git ran before: the saved local
core.hooksPath, else the global one, else.git/hooks, with git's arguments and stdin.
So a hook name only the other tool has (commit-msg, post-checkout) still
runs, and so does a hook added later to .git/hooks or to the saved path.
If another tool later rewrites or removes core.hooksPath (husky install,
pre-commit install), its hooks run and ours stop until repaired:
bash <script-helpers>/scripts/setup-hooks.sh --status --porcelain # taken_over=yes
bash <script-helpers>/scripts/setup-hooks.sh --repair # chain again
--repair makes the new value the previous one, so both run, and keeps the
value it replaced as script-helpers.previous-hooksPath-history. --uninstall
then restores the new tool's value: the setup that was there just before.
--status --porcelain prints one tab-separated line (mode, taken_over,
core_hooksPath, expected, previous, target, claude_repo, global,
agents, unscanned, broken) for tools that check many repositories.
broken=yes means core.hooksPath names a missing directory (a branch
without the hooks, a deleted copy), or the directory our setup points at has no
pre-push. No push checks run then, and git prints nothing. --repair sets it
up again. A repository's own hooks directory with only a pre-commit is not
broken.
Not reachable from git: hooks an IDE runs itself (a JetBrains or VS Code pre-commit check) and server-side hooks. They are never touched, so they keep working, but nothing here runs alongside them.
On a whole machine (optional, off by default)#
None of this changes code in any repository: it writes ~/.gitconfig,
~/.claude/settings.json and copies outside every repository. It does change
what runs in every repository on the machine, so it is opt-in.
git config --global script-helpers.owners <your-github-user-or-org>[,<another>]
# Not needed for your own account when gh is signed in: setup records it as
# script-helpers.github-user. owners adds organisations.
bash <script-helpers>/scripts/setup-hooks.sh --global
bash <script-helpers>/scripts/setup-hooks.sh --status
--global copies the hooks and their own copy of the check scripts to
${XDG_DATA_HOME:-~/.local/share}/script-helpers/git-hooks, prints the current
global core.hooksPath, saves it, and points the global value at the copy. Each hook there calls scripts/global-hooks/dispatch.sh,
which runs the checks and then the hooks that were set before. The
copy does not depend on any checkout; run --global again to update it.
- Checked: a repository where the URL being pushed to (
pre-push) or any fetch or push URL of any remote is on github.com under one of the owners:https://github.com/<owner>/<repo>(with or without.gitor user info),git@github.com:<owner>/<repo>,ssh://git@github.com/<owner>/<repo>. Host and owner must match exactly, case-insensitive:<owner>-forkis another owner. - Fails closed (
scripts/owner_scope.sh): a URL that does not parse but has an owner as a whole path segment (https://github.com.evil/<owner>/x, a mirror on another host) is checked; remotes that cannot be listed mean checked.SCRIPT_HELPERS_OWNERSoverrides the git config. - Every other repository (another org, a fork of someone else's, no remote and a push elsewhere) gets a pure pass-through: the hook git would have run without the install runs (see Chaining) and nothing is added.
- No GitHub account or owners configured:
--globalrefuses (exit 2), and an installed hook passes everything through with one notice per push. - Chaining: after the checks, the hook git would have run without the install
runs, with git's arguments and stdin, and its exit status is the hook's: the
previous global hooks directory's
<hook>if one was set, else an executable.git/hooks/<hook>. Committed hooks (.githooks/<hook>,scripts/git-hooks/<hook>, or a relative earliercore.hooksPath) run only in an owner's repository whose clone opted in locally withgit config --local script-helpers.trust-repo-hooks true(the per-repositorysetup-hooks.shsets it). Anywhere else they never run: that would execute code from whatever was cloned, which git does not do by itself. The checks and the scope logic always come from the installed copy, never from the repository. - A repository with its own
core.hooksPathignores the global value. Runsetup-hooks.shin it: its own hooks are kept and chained (see above).
bash <script-helpers>/scripts/setup-hooks.sh --global --uninstall restores
the saved value (or unsets it) and removes the copy.
Claude Code#
Scope: the hook checks a command only when the session's repository (the
payload cwd) has its origin on github.com under a configured owner. The
origin is read as git uses it (git remote get-url origin, so
url.<base>.insteadOf aliases apply) and judged as the git hooks judge a URL:
one that does not parse but names an owner as a path segment
(ssh://github.com/<owner>/x, port 443) is in scope. Anywhere else (another owner, no repository, no usable cwd, no owners) it
exits 0 and does nothing. The command is not read to widen or narrow that, so
a command run from elsewhere that acts on an owner's repository (cd, -C,
-R, gh api, curl, a path) is out of scope by the owner's decision; that
repository's pre-push hook still covers its pushes.
In scope, scripts/claude-hooks/pretooluse_private_names.sh also runs
check_sensitive.sh on the text of a gh pr create, gh issue comment,
git commit -m and the like, and refuses the tool call on a hit.
It reads -b/--body, --body-file, -t, -m, -F, gh api -f/-F/
--raw-field (and key=@file), --notes/--notes-file, attached short
values (-b'text'), and sees the command behind VAR=value, env and git's
global options. git tag -m counts as publishing. --no-verify on a commit
or push is refused, and so is its short form git commit -n (also in a
cluster such as -an).
Every Bash call is also checked as a whole, whatever it looks like, because a
parser of shell commands always misses some shape bash runs (bash -c,
$(...), backticks, quoting such as g''it, aliases):
- the whole command text, also with quotes and backslashes removed, plus every
file it names that can be read (
--body-file X,-F X,@X,< X), goes through the private-name check andcheck_sensitive.sh --no-machine --no-inline-allow. Paths aftercdand-Care left out of the text. In scope, private names are checked in every command except one contained in a private repository (below): agents cite a private repository by its code in command text, never by name. - contained: the private-name check is skipped (the sensitive-data checks
still run) when the command, as the hook reads it, stays inside one
repository whose every remote is a network URL listed
privatein the name list (a local path remote is never contained). A leadingcdpicks that repository; every othercd, every path (symlinks resolved, also inside a value such as--opt=/xor-t..) and everygh -R/--repomust stay in it, outside its git dir and outside any nested repository.cdtakes only/,./,../or~paths (nocd -, noCDPATHlookup, no~+,~-or~useranywhere), and only at the start or after;,&&or&, with&&or the end of the command after it: elsewhere the next command can run in the old directory. After agitcommand that can rewrite the work tree (checkout,switch,merge,pull,stash, ...) or acpormv(they carry symlinks), only commands that cannot write count, without a redirection (nocd,cp,mv,touch,mkdir,sort,uniq,file): a path checked while missing can be a symlink by then. Only these commands count:cd ls cat head tail wc grep rg sed diff sort uniq cut tr echo printf true pwd stat file mkdir touch cp mv,sedonly assed -n <a>,<b>p,rgwithout--pre,--hostname-binor-z(also inside-iz),sortwithout--compress-programor a prefix of it,gitwith a local subcommand orpush/fetch/pullto a named remote (no-C,-c,--git-dir,--output,--exec,-x,-O,--upload-pack,--receive-pack,--recurse-submodules, or any prefix of those that git would accept), andgh pr|issue create|edit|comment|view|list. No$, backtick, backslash, glob, newline,(,{, operator other than&& || ; | &and redirections, orVAR=valueprefix. Anything else, including a private file sent as the body of an issue in a public repository, gets the full check. Shell aliases, functions andPATHare trusted, as everywhere in this hook. - refused outright:
--no-verify,git commit -n, acore.hooksPathoverride (-c,GIT_CONFIG_*); a command that sets the gate's own overrides or configuration (SECURITY_GATE_ALLOW_UNCHECKED=,SENSITIVE_ALLOW=,PRIVATE_NAMES_ALLOW=,HOME=,export ...,script-helpers.owners, writing the allow, terms or name lists); andeval,xargs,sourceor a variable as the command next to a publishing verb. Overrides count only from the hook's own environment, set by the user.
The private-name check takes seconds on a large list, so a cheap test runs
first: if no listed name occurs in the text in any case, with -, _, .
and spaces ignored, the full check is not needed. The full check is one awk
pass over the list. Measured on a 4,800-name list: about 180 ms per ordinary
command, about 250 ms for a command that names a listed repository (about 14 s
with the old per-name loop). tests/claude_hook_destinations_test.sh holds a hit against
5,000 names under 2 s (5 s under Git Bash on Windows).
Nothing is allowed without that scan: another tool's payload (Write, Edit, if
the hook is wired to them) is scanned as every string of its input; a payload
that is not valid JSON or not UTF-8, or a machine without python3, has the raw
payload scanned, and a publishing command there is refused as "could not
check". The script's header lists every remaining way it allows a call. A check that could not run (no private-name list,
gitleaks missing, a checker error) refuses the call; only
SECURITY_GATE_ALLOW_UNCHECKED=1, set by the user, lets it through. A body
it cannot read (--body-file -, a missing file) and an unreadable payload for
a publishing command count as "could not check". The checkers' exit codes map
one to one: 0 allows, 1 refuses with the reason, 2 refuses as above.
Other AI agents: Codex, Gemini, Copilot CLI, Cursor#
The same check runs before each shell command these agents issue, through
scripts/agent-hooks/agent_hook.sh <agent>: it reads the agent's payload,
runs the Claude hook on the command, and refuses in the agent's own terms
(exit 2, plus its deny JSON). --agent writes that hook into each agent's
own hook file with scripts/agent-hooks/merge_agent_hooks.py, and --claude
writes the Claude Code one into settings.json with
scripts/claude-hooks/merge_settings.py; both keep every other key and hook.
If the check cannot run, the command is
refused, since Cursor otherwise lets a command through on a crash.
bash <script-helpers>/scripts/setup-hooks.sh --agent-repo all # this repository
bash <script-helpers>/scripts/setup-hooks.sh --agent-repo codex,cursor --uninstall
bash <script-helpers>/scripts/setup-hooks.sh --agent all # this user
| Agent | Project file | Event (matcher) | User file |
|---|---|---|---|
| Codex | .codex/hooks.json |
PreToolUse (Bash) |
~/.codex/hooks.json |
| Gemini | .gemini/settings.json |
BeforeTool (run_shell_command) |
~/.gemini/settings.json |
| Copilot CLI | .github/hooks/script-helpers.json |
preToolUse (bash\|powershell) |
~/.copilot/hooks/script-helpers.json |
| Cursor | .cursor/hooks.json |
beforeShellExecution (failClosed) |
~/.cursor/hooks.json |
As with --claude-repo: the project file is listed in .git/info/exclude
before it is written, a tracked or symlinked file is refused, other hooks in
the file stay, backups go under .git/script-helpers/<agent>-backups, and
uninstall removes the file and directories only when this script created
them. Codex and Gemini run a new project hook only after you trust it (Codex:
/hooks). --status --porcelain lists them as agents=. On Windows the
command names Git Bash's bash.exe by full path (a bare bash can be WSL's),
and Copilot gets a powershell command as well, which is the one it runs there.
The payload's cwd decides scope; without one, the directory the agent ran
the hook from is used. An empty payload, or one that is not a JSON object, is
refused.
Set it up on a machine#
Per repository is the default: see "In one repository" above. The commands
below are the optional machine-wide install, both gates for your user: the git
hooks (--global) and the Claude Code hook (--claude). Rerun at any time; a
second run changes nothing, and a run from a newer checkout updates the
installed copy.
Prerequisites#
| What | Why | Check |
|---|---|---|
| bash, git | the hooks are bash scripts | git --version |
Python 3 (python3, or python on Windows) |
parses the hook payload and merges settings.json |
python3 --version |
| Git for Windows (Windows only) | Claude Code runs hooks through Git Bash | git --version in PowerShell |
| owners | only their github.com repositories are checked | your gh account (recorded by setup as script-helpers.github-user), plus git config --global script-helpers.owners |
| gitleaks | secret scan; without it every command in your repositories is refused as "could not check" | setup-hooks.sh --install-gitleaks installs the pinned build |
| private-name list | ~/.config/script-helpers/private-names.tsv; without it every command in your repositories is refused as "could not check" |
refresh_private_names.sh |
Linux and macOS#
git config --global script-helpers.owners <your-github-user-or-org>
bash <script-helpers>/scripts/setup-hooks.sh --all
bash <script-helpers>/scripts/setup-hooks.sh --doctor
Windows#
PowerShell:
git config --global script-helpers.owners <your-github-user-or-org>
powershell -NoProfile -ExecutionPolicy Bypass -File <script-helpers>\scripts\setup-hooks.ps1 --all
powershell -NoProfile -ExecutionPolicy Bypass -File <script-helpers>\scripts\setup-hooks.ps1 --doctor
Git Bash: the same commands as on Linux. setup-hooks.ps1 finds Git Bash
(CLAUDE_CODE_GIT_BASH_PATH, next to git.exe, or the standard install
folders) and runs setup-hooks.sh with the same arguments. Without Git Bash it
stops with exit 2 and names what to install.
What it writes#
| Command | Writes |
|---|---|
| (none) | core.hooksPath and script-helpers.* keys in this repository's .git/config; when chaining, the wrapper copy in <data>/script-helpers/repo-hooks |
--claude-repo |
one entry in this repository's .claude/settings.local.json and a line in .git/info/exclude; the hook copy in <data>/script-helpers/claude-repo-hooks; backups in .git/script-helpers/claude-backups |
--global |
the hooks copy in <data>/script-helpers/git-hooks; global core.hooksPath (old value saved) |
--claude |
the hook copy in <data>/script-helpers/claude-hooks; one PreToolUse entry in ${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json (matcher Bash, timeout 60, "shell": "bash", and "onFailure": "block" when claude --version is 2.1.295 or newer), after a settings.json.bak-<time> copy |
--all |
both |
--managed |
the entry in the OS managed settings file, and the hook copy in script-helpers/ beside it (admin only, see below) |
<data> is %LOCALAPPDATA% on Windows, else ${XDG_DATA_HOME:-~/.local/share}.
Every other key and hook in settings.json is kept; an older entry for the
same hook (another path) is replaced. A settings.json that is not valid JSON
is refused (exit 3) and left as it is. CRLF line endings and a BOM are kept.
--managed writes /etc/claude-code/managed-settings.json (Linux),
/Library/Application Support/ClaudeCode/managed-settings.json (macOS) or
C:\Program Files\ClaudeCode\managed-settings.json (Windows). No user or
project setting can turn that off. It needs an admin shell (sudo bash
setup-hooks.sh --managed, or an elevated PowerShell); without write access it
refuses with exit 2 and never runs sudo itself.
Undo#
bash <script-helpers>/scripts/setup-hooks.sh --all --uninstall # or --global / --claude / --managed
bash <script-helpers>/scripts/setup-hooks.sh --uninstall # in a repository: its git hooks
bash <script-helpers>/scripts/setup-hooks.sh --claude-repo --uninstall
This removes only the script-helpers entry and the installed copies, and
restores the saved global core.hooksPath.
Troubleshooting#
--statusshows what is installed and the prerequisites;--doctoralso runs four probes through the installed hook, from a throwaway repository under the first owner, and prints the time each took: a private name from your list, an SSN-shaped number and a push that skips the hooks must each be refused for that reason, with the deny decision on stdout;lsmust pass. A refusal for another reason (no name list, no gitleaks, a crash) is a failure, and the hook's own first lines are shown. The probed name is never printed.- Without
"onFailure": "block"(Claude Code older than 2.1.295), a hook that times out, or exits with anything but 0 or 2, lets the command run;--statusand--doctorwarn about it. Update Claude Code and run--claudeagain. The timeout is 60 s; the hook normally answers in well under 1 s. A--doctorprobe near the limit means something is wrong. - A refusal is exit 2 with the reason on stderr, and the same reason as a
PreToolUsedeny decision (JSON) on stdout. - In Claude Code,
/hookslists the entry. Settings are read when a session starts: open a new session after installing. no owners configured(exit 2): sign in toghand run setup again, or set the owners (above).- A hook copy that was deleted fails open;
--doctorreports it, and--claudereinstalls it. --statussaysBROKENwhen the entry runs a file that does not exist, andSTALEwhen it runs a file outside the installed copy, such as a checkout that a branch switch or pull changes or removes.--doctorfails on both;--claudefixes both. For scripts,--status --porcelain --claudeprintsclaude_user=ok|absent|broken|stale|unknown(unknown: no Python to read the settings),hook=andinstalled=.