Blog

min read

The Week of Sandbox Escapes: Git directories do not have to be called .git

By

Eilon Cohen

and

July 20, 2026

min read

Escaping Cursor's sandbox with --separate-git-dir and fsmonitor

TL;DR / Executive Summary

This post is part of The Week of Sandbox Escapes, our series on how AI coding agents can turn ordinary project files into paths out of the sandbox. Read the master report here.

We found that Cursor's sandbox protected the obvious Git configuration path, but not every way Git can store trusted repository state. A sandboxed agent could create a repository that looked normal to Cursor, and Cursor's own Git integration later acted on it outside the sandbox. The finding matters because developer tools often treat Git metadata as trusted infrastructure, while agents can now write that infrastructure for them.

Cursor's macOS sandbox had a rule for the obvious dangerous file: .git/config. That is the place where a repository can define hooks, diff drivers, filters, fsmonitor helpers, and other bits of behavior that eventually become process execution. So the seatbelt profile denied writes to that path.

The bypass was that Git does not require the git directory to be named .git.

Git has a first-class --separate-git-dir option. It leaves a small .git file in the worktree and stores the real repository metadata somewhere else. I used .git-alt. Cursor's deny rule was anchored to the literal path .git/config, so .git-alt/config did not match. The agent could write config there, add an fsmonitor hook, and wait for Cursor's own git extension to discover the repository.

When the extension host ran git status, Git read core.fsmonitor and executed the hook outside the sandbox.

The deny rule matched a string, not Git

The seatbelt profile denied writes with a regex like this:

(deny file-write* (regex "^.*/\.git/config$"))

That looks sensible until you remember that .git is not always a directory. With --separate-git-dir, Git creates a .git file whose contents point at the real metadata directory. The actual config lives wherever the caller chose.

In my proof of concept, the metadata directory was .git-alt. That path does not contain /.git/ and it does not end in /.git. It is still a Git directory. The filesystem regex just does not know that.

That distinction is the whole bug. The sandbox tried to protect a path spelling. Git was operating on a repository abstraction.

The chain

The escape took one setup script:

create a probe repository with git init --separate-git-dir=.git-alt

write core.fsmonitor into .git-alt/config

write an executable fsmonitor-watchman helper under .git-alt/hooks

let Cursor's git extension auto-detect the new repository

wait for the extension host to run git status

The attack path is:

sandboxed agent

gitdir indirection

seatbelt regex bypass

git extension auto-trigger

unsandboxed fsmonitor execution

arbitrary code

The user does not need to approve the final execution. The repository scan is editor behavior. From the agent's point of view, it wrote files in places the sandbox allowed. From the user's point of view, Calculator opened.

The payload

The proof of concept created the alternate git directory under a workspace-local probe folder, configured fsmonitor, and dropped a helper that opened Calculator and wrote a timestamp to /tmp.

#!/bin/bash

set -euo pipefail

P="$(pwd)/.gprobe"

G="${P}/.git-alt"

rm -rf "$P" 2>/dev/null || true

mkdir -p "$P" && cd "$P"

git init --separate-git-dir="$G" . >/dev/null 2>&1

mkdir -p "$G/hooks"

cat > "$G/hooks/fsmonitor-watchman" << 'H'

#!/bin/sh

open -a Calculator

echo "ESCAPED $(date +%s)" >> /tmp/gitdir-probe-result.log

H

chmod +x "$G/hooks/fsmonitor-watchman"

git -C "$P" config core.fsmonitor "$G/hooks/fsmonitor-watchman"

touch "$P/trigger.txt"

echo "Probe ready at $P"

Running open -a Calculator from inside the sandbox returned an error in prior testing. Calculator opening here proved the important part: the command was executed by a process outside the seatbelt boundary.

Why fsmonitor is such a sharp edge

core.fsmonitor is meant to make Git faster. Instead of scanning the working tree from scratch, Git can ask a helper what changed. That helper is a program. If a repository config points core.fsmonitor at an attacker-controlled script, any Git command that consults it can become an execution trigger.

That is not a Cursor-specific Git trick. It is ordinary Git behavior. The Cursor-specific part is the handoff: a sandboxed agent can create the repository, and an unsandboxed extension host can later run Git against it automatically.

This is the same shape as the virtualenv finding. The agent writes something that looks like workspace state. A trusted helper outside the sandbox treats it as executable input.

Auto-detection turns setup into execution

If the user had to manually enter the probe directory and run git status, the bug would still matter, but the exploit would be noisier. Cursor's git extension made it cleaner. It watches the filesystem, notices repositories, and probes them.

That probing is useful editor behavior. It is also a trust decision. A repository created during an agent session should not immediately receive the same treatment as a user-created repository. At minimum, the extension should treat newly detected repos with untrusted config as inert until the user confirms them.

Otherwise the sandbox is guarding the front door while the extension host walks around back and executes whatever the new repository asked Git to execute.

The fix

There are two layers to fix.

First, the sandbox needs to reason about Git directories rather than one literal pathname. A .git file that points at another directory is still Git metadata. Writes to the referenced config and hook paths should be controlled the same way writes to .git/config are controlled.

Second, the git extension should not auto-run hooks or hook-like helpers from repositories created by a sandboxed agent. Running Git with config overrides that disable fsmonitor during discovery is a good defensive layer. Requiring confirmation before trusting newly detected repositories is better.

Defaulting stricter git-write protections on also reduces the attack surface. A repository's local config is code-adjacent, and in Git that line is thinner than most people want it to be.

Takeaway

The mistake was not that Cursor forgot one weird Git flag. The mistake was protecting a filename instead of the thing the filename represented.

.git/config is dangerous because Git treats it as local authority. If Git lets that authority live at .git-alt/config, then the sandbox has to follow Git's model, not the string .git. Security controls around developer tools need to understand the abstractions those tools expose, especially when the tool is as configurable as Git.

The broader lesson is the same one that keeps repeating across agent sandboxes: once the agent can shape the workspace, every unsandboxed automation layer that reacts to the workspace becomes part of the attack surface. The sandbox boundary is not just the process. It is every trust handoff after the process writes a file.

Subscribe and get the latest security updates

Back to blog

MAYBE YOU WILL FIND THIS INTERSTING AS WELL

The Week of Sandbox Escapes: The sandbox let me edit a venv, and something else ran it

By

Dan Lisichkin

and

July 20, 2026

Research
The Week of Sandbox Escapes: A time bomb in .vscode: bypassing Antigravity's Secure Mode

By

Dan Lisichkin

and

July 20, 2026

Research
The Week of Sandbox Escapes: The hook was already in the workspace

By

Eilon Cohen

and

July 20, 2026

Research