Git hooks security: what a clone can't carry and a zip can
Guides · Git · Published 20 October 2026
git clone doesn't copy a repository's hooks or its .git/config, so a plain clone can't bring live hooks onto your machine. A zip or tarball can. If it contains the .git folder, its hooks and config are live the moment you unpack it. The first git status that you, your editor or your agent runs in that folder can run a command from its config, and the first checkout or commit runs its hooks. So before you run any git command in a repo that came as an archive, read .git/hooks and .git/config.
This is for developers who've been sent a repo as a zip (a take-home, a “can you fix our site” client, a hand-off) and for anyone who wants to know exactly which git commands run code, which config keys do it, and what the hook tools a normal repo installs actually run.
What a clone copies, and what a zip can carry
Git's own security notes say it plainly: some config options and hook files make git run shell commands, and because config and hooks aren't copied by git clone, you can generally clone a repo with untrusted content and inspect it with git log (git docs, Security). The Pro Git book says the same about client-side hooks (Pro Git, Git Hooks).
The same section draws the line the other way. Running git inside a .git folder that itself came from someone you don't trust runs that folder's config and hooks “in the usual way”, and it names the case directly: a zip file or tarball you extract is owned by you, so git's ownership check doesn't stop it (git docs). The git project repeated this in the advisory for CVE-2024-32465, where the workaround is to avoid using git in repositories obtained via archives from untrusted sources (GHSA-vm9j-46j9-qvq4).
That gap is why some lures arrive as zips. In July 2026 a developer published a take-home project that came as a zip on Google Drive with a PDF of instructions that asked for some git operations. Its .git/hooks/pre-commit fetched a script from a remote server and piped it into the shell in the background (CITIZENDOT's write-up, Hacker News thread). As one commenter put it, hooks aren't checked in to a repo, so the attackers used a zip (HN). The wider pattern is in how fake job interview repos work.
A clone isn't a free pass either. Files committed to a repo can still form a bare repository inside it (below), submodule bugs have let a clone write and run a hook, and the repo's own install step can switch on hooks from tracked files. Each has its own section.
Which hooks run on everyday commands
A hook is any executable file in .git/hooks named after an event. Files that end in .sample or don't have the executable bit set don't run (githooks, Pro Git). These are the ones a normal day of git work triggers:
| Hook | Runs on |
|---|---|
| post-checkout | git checkout and git switch. “Check out the dev branch” is enough. |
| pre-commit, prepare-commit-msg, commit-msg, post-commit | git commit. --no-verify skips pre-commit and commit-msg. |
| post-merge, pre-merge-commit | git merge, which includes git pull. |
| pre-push | git push |
| pre-rebase, post-rewrite | git rebase, and git commit --amend for post-rewrite. |
| reference-transaction | Any git command that updates a reference, such as a commit or a fetch. |
| post-index-change | Whenever git writes the index, which git add does. |
| pre-auto-gc | git gc --auto, which common commands that create objects start on their own (git-gc). |
| fsmonitor-watchman | When core.fsmonitor points at it: commands that refresh the index, like git status. |
All from githooks. Two things widen this list. core.hooksPath moves the hooks folder anywhere, including a tracked folder in the working tree (git-config). And since git 2.54, hooks can be defined in config instead of as files: a [hook "name"] section with an event and a command (2.54 release notes, git-hook). So an empty .git/hooks folder doesn't mean nothing runs on commit; the config has to be clean too.
Config keys that run programs
A zipped .git/config can run as much as its hooks can, and one key doesn't wait for a commit:
- core.fsmonitor. Set to true, it turns on git's built-in file watcher. Set to anything else, it's the path of a command git runs to find changed files, which speeds up commands that refresh the index, like git status (git-config). That makes it the key that fires first: editors with a Git panel and coding agents run git status on their own. VS Code's built-in Git extension does it only once you trust the folder, because it declares no support for untrusted workspaces (its manifest). GitHub fixed exactly this in Copilot CLI, where a bare repo with core.fsmonitor ran a command when the agent ran git (CVE-2026-45033).
- hook.<name>.command with hook.<name>.event: a hook defined in config, git 2.54 and later (git-config).
- core.hooksPath: where git looks for hooks (git-config).
- core.sshCommand: runs instead of ssh on git fetch and git push (git-config).
- credential.helper: a value that starts with ! is a shell command, run when git needs a username or password (git-config).
- core.pager: interpreted by the shell, and runs whenever git pages output, as in git log (git-config). core.editor runs when git opens an editor for a commit or tag message (git-config).
- Filter and diff drivers. .gitattributes (a tracked file, so it does come with a clone) can mark paths with filter=name or diff=name, but the commands live in config: filter.name.smudge runs on checkout, filter.name.clean on check-in, diff.name.command and diff.name.textconv on diff. With no definition in config, a filter is a no-op (gitattributes, filter, diff drivers). The pair only becomes dangerous when the config ships too.
- Aliases that start with ! are shell commands, but only when you type the alias; git ignores aliases that would hide a built-in command like status (git-config).
- include.path and includeIf.*.path pull in another config file, so the dangerous key can sit in a file that isn't .git/config (git-config).
Some keys git refuses to read from a repository's own config at all, such as safe.directory, safe.bareRepository and uploadpack.packObjectsHook. They count only in your system or global config or on the command line (git-config). The keys in the list above have no such limit.
Check a zipped repo before you run git in it
Unpack it, but don't open it in your editor and don't run git in it yet. Reading files with ls, cat and grep doesn't involve git, so nothing in .git runs:
cd handoff # the unzipped folder ls -la .git/hooks | grep -v '\.sample$' # anything listed here is a live hook cat .git/config ls .git/modules 2>/dev/null # submodules keep their own config and hooks here cat -v .gitmodules .gitattributes 2>/dev/null # -v shows a stray carriage return as ^M find . -name HEAD -not -path './.git/*' -not -path '*/node_modules/*' # nested or bare repos
A .git/config written by git clone has [core], [remote "origin"] and [branch] sections. Anything else needs a reason. To pick out the keys from the previous section (a hit is something to read, not proof):
# every file named config under .git: the repo's own and each submodule's grep -rniE --include=config '^[[:space:]]*(fsmonitor|hookspath|sshcommand|askpass|pager|editor|helper|command|textconv|clean|smudge|process|driver|cmd|program|path)[[:space:]]*=|^[[:space:]]*\[(hook|include|includeif|alias|filter|diff|merge)' .git
Submodule git folders live under .git/modules in the parent repo (gitsubmodules), and each has its own config and hooks, so check those the same way.
Get a clean copy instead of working in the zip
Git's docs recommend cloning an untrusted .git folder with --no-local to get a clean copy (git docs). The new copy has fresh config and no hooks; you work there, not in the original:
cd .. git clone --no-local handoff handoff-clean cd handoff-clean && git branch -a
The same docs add a caveat: git limits what the serving side of a clone runs, but that side has a large attack surface, so this still carries some risk (git docs). It also brings only committed history, not uncommitted files in the zip. If you don't need the history, the blunt option deletes the zip's git folder (hooks, config and history) and starts a new one:
rm -rf .git && git init
Why command-line overrides aren't enough
Values passed with git -c override the config files (git docs), and -c core.hooksPath=/dev/null is git's documented way to switch off the hooks folder for one command (git-config). So this turns off the two most likely triggers:
git -c core.fsmonitor=false -c core.hooksPath=/dev/null --no-pager log --oneline -5
It doesn't cover the rest of the list: filters, credential helpers, sshCommand and included files still apply. And in a test on git 2.54.0, -c core.hooksPath=/dev/null stopped a hook file in .git/hooks but not a hook defined with hook.<name>.command in the same repo's config, which git runs alongside the hooks folder (git-hook). Read first; use overrides only as a second layer.
Hooks a cloned repo installs: Husky, lefthook, pre-commit
Plenty of ordinary repos ship hooks as tracked files and a tool that switches them on. These do come with a clone. They're usually lint and test commands, but read what they run, because the switch is often part of install.
- Husky. The recommended setup puts husky in the prepare script so it runs after each install (Husky docs), and npm runs prepare on a plain npm install (npm docs). Husky's install sets core.hooksPath to .husky/_ (source), and from then on the scripts in .husky/ (.husky/pre-commit and so on) run on the matching git command. HUSKY=0 skips the install and turns the hooks off for a command (Husky docs).
- lefthook. The npm package installs the git hooks from its own postinstall script (lefthook docs), and each run: line in lefthook.yml is a command it runs with sh (lefthook docs). A remotes: block downloads config from another git repository and merges it in, so what runs on commit can change without a change in this repo (lefthook docs).
- pre-commit. Nothing runs until you run pre-commit install; after that the hooks in .pre-commit-config.yaml run on every commit. Each repo: entry is a git URL that pre-commit clones at rev, so the hook code comes from there, and a repo: local hook runs whatever its entry says (pre-commit docs). If you've set up pre-commit init-templatedir with git's init.templateDir, every repo you clone gets the hook without asking (pre-commit docs).
npm install --ignore-scripts stops prepare and postinstall, so neither Husky nor lefthook switches its hooks on (npm docs). The rest of what runs on install is in npm install scripts and ignore-scripts. Also watch for an install script or editor task that runs git config core.hooksPath on a folder like .githooks/.
Submodules, nested repos and your git settings
Several of the bugs that let a clone run code came through submodules, and those need --recurse-submodules (or a later git submodule update --init), which clones every submodule listed in .gitmodules (git-clone):
- CVE-2024-32002 (fixed 14 May 2024): on case-insensitive filesystems that support symlinks, a crafted submodule could write a hook into .git/ that ran while the clone was still going. Fixed in 2.45.1, 2.44.1, 2.43.4, 2.42.2, 2.41.1, 2.40.2 and 2.39.4 (advisory).
- CVE-2025-48384 (fixed 8 July 2025): a carriage return at the end of a submodule path got the submodule checked out to the wrong place, and with a symlink pointing that place at the hooks folder, its post-checkout hook ran. Fixed in 2.50.1, 2.49.1, 2.48.2, 2.47.3, 2.46.4, 2.45.4, 2.44.4 and 2.43.7. The advisory's workaround is to upgrade or avoid recursively cloning submodules in untrusted repositories (advisory). That's what cat -v .gitmodules above looks for.
A clone can also contain a bare repository: an ordinary folder with HEAD, config and objects/ committed as plain files. Git finds it when a command runs inside that folder and applies its config. That was the Copilot CLI bug above, fixed in 1.0.43 (advisory). The find . -name HEAD line in the previous section turns these up.
Settings worth changing
git --version # compare with the fixed versions above git config --global safe.bareRepository explicit # ignore bare repos git finds by walking into a folder
- safe.bareRepository=explicit makes git use a bare repository only when you name it with --git-dir or GIT_DIR, which the docs describe as protection against a cloned repo that contains a bare one. It will be the default in Git 3.0 (git-config).
- Don't set safe.directory to *. That opts out of git's check on repositories owned by another user (git-config). The check doesn't help with a zip you unpacked yourself, but it's the right default for everything else.
- Skip --recurse-submodules until you've read .gitmodules.
- Keep VS Code's workspace trust on, so its Git panel doesn't run git in a folder you haven't trusted (VS Code tasks and Restricted Mode).
What PreClone shows for a zipped repo
Drop the zip or tarball on preclone.dev and it's read inside your browser tab, without running anything. For git it reports live hooks in .git/hooks and in submodule folders, commands in a bundled .git/config (core.fsmonitor, filters, sshCommand, pager, editor, diff and merge drivers, credential helpers, aliases, core.hooksPath and included files), bare repositories hidden in the project, submodule tricks in .gitmodules including the CVE-2025-48384 carriage return, and the Husky, lefthook (including remotes), pre-commit and simple-git-hooks commands the repo installs. The zip hand-off example is a client zip with a post-checkout hook and a core.fsmonitor command, both set to fire on “unzip, check out the dev branch”. The full list is on what it checks.
It reads files, so it can't see what a hook downloads when it runs, the code inside a pre-commit hook's remote repo, binaries, or tricks nobody has written a rule for. It doesn't yet flag hooks defined with git 2.54's hook.<name>.command, so read for [hook sections yourself. The best it will say is “Nothing flagged”. For the other files to read before you open a repo, see how to check a GitHub repo for malware.
If a hook already ran
If you ran git status, a checkout or a commit in a zipped repo with a live hook or a command in its config, assume the command ran with your user's permissions. In the July 2026 case the hook pulled a second stage that installed npm packages and started a Node script in the background (CITIZENDOT), so deleting the folder doesn't undo it.
- Don't delete the folder yet: the hook files and .git/config show what ran.
- Disconnect the machine and stop using it for anything that touches a password or token.
- Follow what to do after running a malicious repo: rotate credentials from a clean device, check for persistence, and consider a reinstall.
Questions
- Does git clone run hooks from the repository?
- No. Git doesn't copy a repository's hooks or its .git/config when you clone it. The exceptions have been bugs, such as the submodule flaws fixed in May 2024 and July 2025, so keep git updated and don't use --recurse-submodules on a repo you haven't read.
- Why would a take-home project come as a zip instead of a GitHub link?
- Because a zip can include the .git folder, and with it live hooks and config that a clone would leave behind. If a recruiter or client sends a zip with a .git folder and asks you to check out a branch or commit, read .git/hooks and .git/config before you run any git command in it.
- Is git fsmonitor dangerous?
- core.fsmonitor = true turns on git's built-in file watcher. Any other value is a command git runs to speed up commands like git status, so in a .git/config you didn't write it runs on the first git status, including one your editor or agent runs.
- Does safe.directory protect me from a zipped repo?
- No. It makes git refuse repositories owned by another user, and the files you unzip are owned by you. Git's own docs say a repo from an untrusted archive has to be cleaned up first.
- How do I turn off git hooks for one command?
- git -c core.hooksPath=/dev/null turns off the hooks folder for that command, and git commit --no-verify skips the pre-commit and commit-msg hooks. Neither touches core.fsmonitor, filters or the pager, and on git 2.54 the hooksPath override doesn't stop hooks defined in config.
Check a repo before you open it
Paste a GitHub, GitLab or Bitbucket link. PreClone reads the repo without running it and lists what would run on your machine, by when it runs. Free for public repos, no login. It can miss things, so it never calls a repo safe.
Or see an example report first.
Related guides
- How to check a GitHub repo for malware before you clone itFake take-home repos run code on folder open or npm install. Which files to read first, which settings to change, and what to do if you already ran it.
- Fake job interview malware: how the take-home repo infects you, and what to check firstHow the fake recruiter take-home works, which repo files start the malware (tasks.json, configs, postinstall, git hooks) and what to do in the first minute.
- What npm install runs: postinstall, prepare and the scripts nobody readsThe scripts npm install runs, in order, for a repo and its dependencies. How npm 12, pnpm, Yarn and Bun now gate them, and what --ignore-scripts doesn't stop.