Every CLI coding agent ships an allowlist of commands it may run without asking, and git status, git diff and git log sit at the top of all of them. The reasoning is that they do not write anything. That holds for the command and breaks for the repository, because whether git status spawns a process is decided by .git/config, a file the repository author controls.
The short version: Git's own documentation already classifies the repository config scope as attacker-controllable, and then honours dozens of keys from it whose value is a program to run. Blanking core.fsmonitor closes one delivery route. The second route arrives through a clone, needs safe.bareRepository=explicit, and four vendors have now shipped four different answers to the same bug class.
What Git already decided about .git/config
Read git-config(1) on protected configuration. Git defines it as the system, global and command scopes, states that certain options are "only respected when they are specified in protected configuration, and ignored otherwise," and gives the reason local config is excluded: "it is relatively easy for an attacker to control local config."
That boundary has been published for years. Git applied it to exactly two things, safe.directory and uploadpack.packObjectsHook. Everything else that takes a command as its value stayed readable from .git/config by design: core.fsmonitor, core.hooksPath, core.sshCommand, core.askPass, core.pager, diff.<driver>.textconv, diff.external, filter.<driver>.clean, merge.<driver>.driver, gpg.program, alias.*, credential.helper. An agent that treats git as a read-only verb has inherited a trust decision Git never made.
Manifold Security published the collision on 4 September 2026 as GitSpawn: eight findings across seven agents (Claude Code, Codex, Cursor, Goose, Hermes Agent, Qwen Code, Grok Build) where a repository-supplied core.fsmonitor value executed on the host during the agent's startup probe, in several cases before the workspace-trust dialog rendered. Claude Code's path was fixed in 2.1.196. Goose took CVE-2026-72718 and fixed it in 1.44.0. Codex CLI and Desktop took CVE-2026-19592 and CVE-2026-19593. Manifold's writeup records four findings still unpatched at publication.
The interesting part is not core.fsmonitor. It is that the vendor response has been to blank keys one at a time, and the patch sets disagree with each other. If you have spent any time proving what an agent's write access actually covers, this is the same shape of problem one layer down: the permission model names a tool, and the tool's behaviour is configured by the data it is pointed at.
Two ways a hostile config reaches you, and the check that tells them apart
Manifold says "cloning a hostile URL does nothing." True for the top-level .git/config, false for the primitive. GitHub's advisory for Copilot CLI, CVE-2026-45033 (GHSA-9ccr-r5hg-74gf, affecting @github/copilot <= 1.0.42), documents the clone-delivered variant: a bare repository committed into the tree at a path such as vendor/malicious.git/, which Git discovers during traversal and whose config it then honours. Justin Steven wrote that route up in 2022. It travels through a pull request without anything unusual in the diff.
So one observable, an agent executing attacker code while the user reads a prompt, has two unrelated delivery paths needing different controls. Most of the published advice covers one each.
safe.bareRepository=explicit only cuts the right-hand edge. It does nothing when the repository arrived as a directory with its .git already inside, because nothing in that case is bare. Set it anyway: it is the only control for the clone route.
Four patches, four key lists
| Product | Fixed in | What it pins | Route closed |
|---|---|---|---|
| Claude Code | 2.1.196 | core.fsmonitor | directory handoff |
Copilot CLI (@github/copilot) | 1.0.43 | safe.bareRepository=explicit via GIT_CONFIG_KEY_* | buried bare repo only |
| Codex CLI / Desktop | PR #24954, merged 28 May 2026 | core.fsmonitor=false, null core.hooksPath, filter.* clean and process, plus --no-textconv --no-ext-diff | both, on probe and diff paths |
| DeepSeek-Reasonix Studio | 2.21.0, npm 1.39.3, 30 Sep 2026 | filter driver resolution on diff open | clone-delivered .gitattributes |
OpenAI's Codex PR #24954 is the broadest published patch, and the only one that treats the problem as a family rather than a key. Copilot CLI 1.0.43 closes the clone route and leaves a hostile top-level config intact. Those two products were patched for the same CVE class and now have non-overlapping coverage.
GitLab's Threat Research Group found the same family in DeepSeek-Reasonix Studio and filed it as ConfigPoisoning, CVE-2026-102437 / GHSA-grg2-7gc6-36m6. That one does not fire at startup. It fires when a developer opens a file's diff.
How does the tree pick which configured command runs?
ConfigPoisoning is the instructive case. .gitattributes is a tracked, clone-delivered file that selects a filter driver by name, and .git/config supplies that driver's clean command. Two files, two trust zones, one execution.
A key-level denylist has to anticipate every possible driver name. Pinning filter.* wholesale is the only version that survives contact, and the Codex patch does exactly that. The same structure applies to diff.<driver>.textconv and diff.<driver>.command, which is why --no-textconv and --no-ext-diff belong on the invocation rather than in a config sweep.
There is also a fetch-time and submodule-time sink that no fsmonitor patch touches. Git gates ext:: URLs, which execute an arbitrary binary as a remote helper, behind protocol.ext.allow. That key does not appear in git-config(1)'s protected-configuration list. Before you build a threat model on it, test it: set protocol.ext.allow = always in a throwaway repo's local config, run git ls-remote "ext::sh -c 'id >&2'", and see whether your Git version honours it. If it refuses, the remaining exposure is the ext:: URL in .gitmodules, which is narrower but not zero. The allow-git=root story in npm rhymes with this: one named control, four distinct paths to the same outcome, and the control covers one.
Where .git crosses a trust boundary in your pipeline
actions/checkout writes credentials into .git/config as http.extraheader. That already establishes local config as pipeline-written and secret-bearing, and it means the file moves whenever the directory does. An artifact upload with path: ., a docker build context with no .dockerignore entry for .git, a restored workspace cache, a scp of a working tree between hosts: each of those carries a config file from one trust zone into another.
Manifold framed the directory-handoff route as a USB stick passed between consultants. The more common shape is a default artifact step in a workflow nobody has read since it was written. Treat .git the way you treat a credentials file, because in CI it is one. The gaps that survive pinning GitHub Actions by SHA include this: the action is pinned, the workspace it hands onward is not.
The useful asymmetry is that inspection is free. git config --list --show-scope --show-origin parses the file; it does not refresh the index and does not invoke a driver. grep is safer still. The sinks fire on operations that refresh the index or render a diff, so auditing before invocation is a real option rather than a theoretical one.
Run this before the agent opens the directory
- Audit the untrusted scopes by name, not by eyeballing the file:
git config --list --show-scope --show-origin \
| grep -Ei '^(local|worktree)' \
| grep -Ei 'fsmonitor|hookspath|sshcommand|askpass|pager|editor|alternaterefscommand|credential\.|diff\..*(external|textconv|command)|merge\..*driver|filter\.|alias\.|protocol\.|url\..*insteadof|uploadpack|gpg\.program|maintenance\.auto'
A hit on any of those is a decision to make, not a warning to dismiss.
- Find the clone-delivered variant separately. It is not in the top-level config:
for h in $(find . -name HEAD -not -path '*/.git/*'); do
d=$(dirname "$h")
[ -d "$d/objects" ] && [ -d "$d/refs" ] && echo "== $d" && git config -f "$d/config" --list
done
git config --global safe.bareRepository explicit
- Fix the allowlist, not the agent version. An entry reading
git diffis a code-execution grant. This is not:
git --no-optional-locks -c core.fsmonitor=false -c core.hooksPath=/dev/null diff --no-ext-diff --no-textconv
Carry the flags in the allowlist pattern so an unflagged invocation falls through to a prompt. An allowlist that matches on the verb and ignores the flags is a gate failing open by construction.
- Pull
.gitout of the pipeline's data flow. Add it to.dockerignore, exclude it from artifact uploads and workspace caches, and prefer a freshgit cloneover restoring a saved directory. Anything that reachespath: .has already made the decision for you. - Ask your vendor which keys, at which scope. A security note that says "fixed a code execution issue in Git handling" tells you nothing actionable. One that says "pins
core.fsmonitor,core.hooksPathandfilter.*at command scope and passes--no-ext-diff --no-textconv" tells you whether the ConfigPoisoning variant is still open in your install. Claude Code 2.1.196, Copilot CLI 1.0.43, Codex PR #24954 and Studio 2.21.0 all shipped against the same class, and only one of them pinned a family instead of a key.
Comments
Be the first to comment.