Skip to content

← The log

What runs when git checks out code you have not read

Four ways a checkout runs a program, every one of them the reviewer's own config and one of them aimed by the branch — and the exposure starts at clone, before a tree exists. Measured on git 2.54, and closed down to one stated residual.

FixKonstantin Tarkus

Engwire’s security note said it checks out a revision and does not execute it. That sentence is not a decision Engwire gets to make — it is a property of git, and git will run a program during a checkout in more places than its documentation collects in one spot.

So the sentence was tested rather than trusted: git 2.54.0, driven through the real clone and checkout paths, against an origin carrying each vector in turn. Four fired, and closing them changed how Engwire acquires a repository at all — worktree add --no-checkout followed by a separate reset --hard, with targeted config overrides on the clone, the fetch and both halves of the checkout. The rest of this is why each of those pieces is there.

Anyone whose tool clones a branch it has not read has the same problem: CI runners, review bots, dependency scanners, coding agents.

What does not travel

The good news first, because the mistake runs both ways — some people assume a checkout executes nothing, others assume a contributor can ship them a hook by committing one. Neither is right.

Repository-local configuration and hooks do not come across a clone. A filter.evil.smudge defined in the origin’s own config, a .git/hooks/post-checkout sitting in the origin, and a core.hooksPath set locally there all failed to run. The clone’s config held nothing beyond what clone itself writes.

Clone transfers refs and objects. It does not transfer configuration, and it does not transfer hooks.

The four that did run

What runs instead is your configuration, meeting code you have not read. Two of the four are post-checkout reached by different routes; the third is a hook git does not call a hook; the fourth is the only one a contributor can aim.

1. post-checkout, via an absolute core.hooksPath

git worktree add runs post-checkout, with the new worktree as its working directory.

A relative core.hooksPath — which a reviewer may well have set globally to share hooks across their own repositories — resolves next to the bare clone rather than inside the worktree. .githooks means <clone>/.githooks, a path the branch cannot write to. That one is safe by accident of resolution.

An absolute one is not. It is the reviewer’s own script, written for repositories its author trusts, and it ran in the contributor’s checkout.

2. post-checkout, via a configured hook command

Since git 2.54 a hook can also be configured outright, as hook.<name>.command with hook.<name>.event = post-checkout. core.hooksPath does not cover that source at all. With the hook path pointed at /dev/null, the configured command still ran — and git show HEAD:file from inside it read the branch’s own blobs.

There is a trap in switching that one off:

# Does nothing. `enabled` is keyed by the hook's own NAME, not by the event.
git -c hook.post-checkout.enabled=false ...

hook.<name>.enabled = false is git’s documented way to disable a hook, and it reads post-checkout there as a name. If you have configured a hook called something else that listens for post-checkout, disabling the event is not a thing you can spell.

3. core.fsmonitor is a program too

Git documents a non-boolean value for core.fsmonitor as the pathname of a hook, and worktree add refreshes the index through it. The script ran, with the new worktree as its working directory.

Disabling it takes an empty value rather than false, and the reason is a version split. Git through 2.35.1 is documented as reading the value here as a pathname whether or not it looks boolean — on those versions false names a program rather than turning anything off. On 2.54.0 nothing ran for either spelling, including where the reviewer had set it to true, which newer git reads as the built-in monitor. Empty is the one spelling that means the same thing on both sides of that split, so that is what ships.

4. A committed .gitattributes can fire your global filters

A .gitattributes file in the branch naming filter=evil activates a filter.evil.smudge defined in the reviewer’s global config. It ran during checkout, with the file’s contents on its stdin.

The contributor cannot choose what runs — the command is configuration the reviewer wrote, for their own reasons, long before this pull request existed. But they choose whether it runs, and on what. The attacker supplies the trigger; the victim supplies the payload.

That is not hypothetical. The machine these measurements were taken on has git-lfs 3.7.1 installed, and a user-level git lfs install defines filter.lfs.smudge, filter.lfs.clean, filter.lfs.process and filter.lfs.required = true globally. If you have run it at user scope and not removed it since, you have a global filter a committed .gitattributes can point at.

It starts before the checkout

Getting the repository is not a safe prelude to the risky part. reference-transaction fires on every clone and every fetch that updates a ref, from both hook sources, before any tree exists — the reviewer’s own hook, running while a repository nobody has read is still downloading.

So the overrides go on clone and fetch too, not on the checkout alone. A tool that hardens only the moment code lands on disk has hardened the second half of the exposure.

Closing it was harder than disabling four settings

The obvious move is a blunt one: GIT_CONFIG_GLOBAL=/dev/null and be done. It does work, and it was rejected. Engwire’s clone is blobless, so the checkout still goes back to the network for blobs — and it needs whatever proxy and credential configuration the reviewer’s global file carries to get them. Targeted overrides survive that; a scorched global does not. Since git has no wildcard for “disable executable config”, targeted means finding each key by name.

Which turned up four more things.

The checkout answers to a second repository. A plain git worktree add does its checkout in a child process with GIT_DIR set to the new worktree’s gitdir, <clone>/worktrees/<name> — not the bare clone. Configuration the reviewer scoped to that path is therefore invisible from where you are enumerating: [includeIf "gitdir:**/worktrees/**"] matches the one and not the other, and a smudge filter behind such an include ran with every override in place, because the enumeration never saw it.

That is what the --no-checkout split is for: create the worktree empty, fill it with a separate reset --hard, and override each half against the gitdir it actually runs in. The split also changes which hooks fire — post-checkout stops running altogether, while reference-transaction fires in both halves and post-index-change in the second. Those are the events worth pinning a regression test to.

required has to be overridden too. With the check-out-side commands disabled, a filter still marked required does not quietly fall back to unfiltered content. It fails: fatal: .gitattributes: smudge filter evil failed, exit 128, no worktree. Since git-lfs marks its filter required, disabling the commands alone would have broken the checkout of every LFS repository.

A disabled process does not fall back to a smudge beside it. Each key has to be overridden on its own account; none of them stands in for the rest.

Only the check-out direction reaches you. A filter defining nothing but clean did not run as the tree was written, so the overrides cover smudge, process and required, and leave clean as the reviewer set it.

Two ways an override silently misses

Both of these bite whoever writes the overrides, not whoever reads them.

GIT_DIR outranks the working directory. git -C <dir> with GIT_DIR exported operates on $GIT_DIR, not on <dir>. Naming a directory guarantees nothing on its own, which is why Engwire’s git wrapper strips GIT_DIR and its relatives from the environment it hands to git and passes everything else through.

-c cannot express every key. A git config subsection name may legally contain an =, and git -c <name>=<value> splits on the first one. Against a real [filter "a=b"], the argument

git -c filter.a=b.smudge= ...

sets filter.a to b.smudge= — and the smudge filter runs. Your override silently addressed a key that does not exist.

--config-env=<name>=<var> splits on the last = and takes the value from the environment, which blocks it. It also exits 128 when the named variable is missing, so a mistake there is loud rather than silent. That is the spelling to use when the key is not fully under your control.

What this does not cover

One residual is stated rather than closed. A fresh clone has no configuration of its own to enumerate, so config the reviewer scoped by includeIf to Engwire’s own clone path stays invisible to it. Nothing in a branch can ask for that: it takes the reviewer having pointed a rule at a directory Engwire had not created yet. Every other form this experiment found is covered, which is a different claim from every form there is — an executable configuration source nobody has thought to look for would not be.

One cost, too: a file that really is an LFS pointer stays a pointer in the checkout, because the filter that would expand it is the same one being held off.

And none of this makes a checkout a sandbox. Once an agent is running in that directory, what it may do is whatever its tool permissions allow — for Engwire, the allowed-tools of the skill you configured. What closed here is the narrower gap where merely placing the code on disk executed something. It says nothing about what you then point at the code, and no configuration flag will.

The arenas and the exact commands are in docs/experiments.md, next to the code they justify, so a newer git is an afternoon of re-running rather than a reconstruction.

engwire/engwire#12 →

Engwire reviews the pull requests that ask for you, on your own machine.

MIT · macOS and Linux · no account to make

curl -fsSL https://engwire.com/install.sh | sh

What Engwire does →