Dev.to AI 🤖 Ai 👁 0 📖 9 min read

A Trailing Dot Let Copilot Rewrite Its Own .mcp.json. How Windows Paths Fool Denylists

Book: System Design Pocket Guide: Interviews The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series My project: Hermes

A Trailing Dot Let Copilot Rewrite Its Own .mcp.json. How Windows Paths Fool Denylists

.mcp.json and .mcp.json. are two different strings. On Windows, written through the normal file APIs, they are the same file.

That one character is CVE-2026-103572. Johann Rehberger published the details on Embrace The Red on October 13, 2026, the same day Microsoft shipped the fix in Patch Tuesday. Copilot in Visual Studio and SQL Server Management Studio (SSMS) was meant to stop the agent from writing to its own MCP and settings files. The check compared file names as strings, so a name with a dot on the end was not on the list. Windows then dropped that dot on the way to disk, and the write landed on the protected file. Microsoft rated it Important, CVSS 8.8, per the same post.

The bug is old-school Windows path handling. The target is new: the file that decides which programs your coding agent is allowed to launch.

From a poisoned file to calc.exe

Rehberger's chain starts with indirect prompt injection: instructions hidden in a file you open with Copilot, or in data an MCP server returns. Once the agent follows them, it has two routes to running a command.

  1. The MCP route. Add a stdio server to .mcp.json whose command is the attacker's program, put its name in enabledMcpServers and take it out of disabledMcpServers. The IDE launches the server, which runs the command.
  2. The search route (Visual Studio only). Point copilot.general.tools.ripgrepPath in settings.VisualStudio.json at any binary. It runs the next time Copilot searches. The proof of concept launched the calculator.

Both files were on a protected list checked in EditsFunctionHandler.GetEditConfirmationTypeAsync, part of Microsoft.VisualStudio.Copilot.Core.dll, which Visual Studio and SSMS share. Writing ~/.mcp.json. or settings.VisualStudio.json. got past it. In Rehberger's words: "The validation passes because the two strings are different."

The same post documents a second hole of the same family: paths that started inside the trusted Extensions folder could climb out with ..\, which in SSMS let the agent read SSMS.isolation.ini and learn the installation ID needed to find the per-install settings file.

Why ~/.mcp.json matters: Visual Studio reads MCP configuration from five places, starting with %USERPROFILE%\.mcp.json, which loads "for all Visual Studio solutions", then .vs\mcp.json, .mcp.json, .vscode\mcp.json and .cursor\mcp.json under the solution folder. The same page says the trust dialog added in Visual Studio 2026 18.7 is skipped "the first time the server is seen".

The timeline from Rehberger's post: reported August 4, confirmed by Microsoft September 16, fixed October 13, with a $0 bounty that Rehberger publicly disagrees with. The Cloud Security Alliance research note lists the fixed builds as 17.14.42 for Visual Studio 2022 and 18.10.4 for Visual Studio 2026. It also points out that Microsoft's affected-product list names only those two Visual Studio versions even though the CVE title mentions SSMS, so SSMS admins should confirm their update with Microsoft.

If you use Copilot in Visual Studio, check your build number before reading further.

What Windows does to a path before NTFS sees it

Microsoft's .NET page on Windows path formats lists what happens to almost every path passed to a Windows file API. Windows identifies the path type, applies the current directory to relative paths, turns / into \, evaluates . and .., and then trims characters: "If the path doesn't end in a separator, all trailing periods and spaces (U+0020) are removed."

So .mcp.json. and .mcp.json both reach the file system as .mcp.json. The same page adds that name comparisons are case-insensitive, so .MCP.json is that file too.

A path that starts exactly with \\?\ skips normalization. The Win32 naming docs say that prefix tells the APIs "to disable all string parsing and to send the string that follows it straight to the file system". The .NET page gives the consequence: a file called hidden. "is impossible to access in any other way". NTFS itself can store .mcp.json. as a separate name. The Win32 layer above it folds the two together.

A long scroll of a file name passing through a press that trims a single dot off its end before it drops into a filing cabinet
A name goes in with a dot on the end and comes out without it.

In an agent tool, the agent picks the string the check sees.

Reproduce it with a short Python script

This needs a Windows machine with an NTFS drive. Put it in an empty folder and run it there. os.path.abspath on Windows calls GetFullPathNameW under the hood (CPython's ntpath.py), and os.path.normcase lowercases the result.

# trailing_dot.py: run on Windows, in an empty folder
import os

def canon(p):
    # GetFullPathNameW via abspath, then case-fold
    return os.path.normcase(os.path.abspath(p))

DENY_NAMES = {".mcp.json"}
DENY_PATHS = {canon(".mcp.json")}

for p in [".mcp.json.", ".mcp.json ", ".MCP.json"]:
    naive = p not in DENY_NAMES
    fixed = canon(p) not in DENY_PATHS
    print(repr(p), "naive:", naive, "canonical:", fixed)

with open(".mcp.json.", "w") as f:
    f.write("{}")
print(os.listdir("."))

raw = "\\\\?\\" + os.getcwd() + "\\.mcp.json."
with open(raw, "w") as f:
    f.write("{}")
print(os.listdir("."))
os.remove(raw)

I haven't run this on a Windows box myself, so here is what Microsoft's documented rules predict rather than a pasted transcript. The naive check allows all three names. The canonical check blocks all three. The first listing shows .mcp.json next to the script and no name ending in a dot, because the trailing dot was trimmed before the file was created. The second listing, after the \\?\ write, has both .mcp.json and .mcp.json., because that prefix skips the trim and NTFS stores the name as given. The cleanup uses the same prefix, since no other path reaches that file.

Why GetFullPath fixed it, and what it still misses

Rehberger's reading of the patch is that "a call to GetFullPath was added, which removes the dot and normalizes the filename", which "should also prevent some of the path traversal variants". The CSA note stresses that this is the researcher's reading, not a statement from Microsoft. It makes sense either way: Path.GetFullPath wraps GetFullPathName, the same normalization Windows applies when it opens the file. Run the check on that string and the check and the write finally agree.

On its own, though, it does not close every alias for a file:

  • Case. GetFullPath keeps your casing. Compare case-insensitively, or fold both sides.
  • 8.3 short names. GetFullPathName "does not convert the specified file name". A short name like PROGRA~1 stays short unless you call GetLongPathName on a file that exists.
  • Links and junctions. A junction inside the allowed folder can point at your profile. Python's os.path.realpath resolves symlinks and junctions on Windows since 3.8, and 8.3 names too.
  • Streams. .mcp.json::$DATA is, per the File Streams docs, "the equivalent of" .mcp.json, and none of the normalization steps above removes it.
  • Device prefixes. A \\?\ path is normalized when you pass it to GetFullPathName explicitly, but not when you open it, so the check and the write can see different names. Refuse agent-supplied \\?\ and \\.\ paths outright.

The other rule is to write to the path you checked. The GetFullPathName page says a relative path "should be consumed exactly once": convert it, then use the converted path from that point on. Here is a guard that compares case-folded paths but returns the resolved path with its casing intact. The caller writes only to what it returns:

import os

def resolve(p):
    # dots, spaces, .. then links, junctions, 8.3
    return os.path.realpath(os.path.abspath(p))

def key(p):
    # case-folded, for comparisons only
    return os.path.normcase(resolve(p))

def safe_target(p, root, protected):
    if p.startswith(("\\\\?\\", "\\\\.\\")):
        return None
    real = resolve(p)
    k, base = os.path.normcase(real), key(root)
    if ":" in os.path.splitdrive(k)[1]:
        return None                  # NTFS streams
    try:
        if os.path.commonpath([k, base]) != base:
            return None              # outside the root
    except ValueError:               # different drive
        return None
    if k in {key(x) for x in protected}:
        return None
    return real                      # write here, not to p

That still leaves a race: a junction swapped in between the check and the write. Closing it means opening the file first and checking the path of the handle you got, which is beyond a blog snippet.

Keep agent config out of the agent's reach

Rehberger closes with the line that matters here: "Security controls around agents must hold even when the agent itself is compromised or misaligned." A denylist checked inside the agent's own write tool assumes the agent passes it honest strings.

A few moves follow from that, whatever tools you use:

  • Run the protected-path check in the host or IDE, outside the write tool the agent calls, and run it on the resolved path.
  • Treat a write to any MCP or agent settings file as something a person confirms outside the chat flow, where injected instructions can't answer for you.
  • Remember that %USERPROFILE%\.mcp.json sits outside every repo. A repo review won't show a change to it, so the patch and OS-level isolation are what cover that file.

I build Hermes IDE, so weigh this part with that in mind, and I'll start with what it does not do. Hermes runs each agent in a real terminal, exactly as it would run anywhere else. It is not a sandbox. An agent in Hermes can write to ~/.mcp.json as easily as one in a plain terminal. If your threat model includes that, the answer is a separate user account, a VM or a container, not an IDE.

Where it helps is the config that lives in the repo: .mcp.json, .vscode/mcp.json, .claude/settings.json, AGENTS.md. Each task gets its own hermes/ branch and its own worktree, so a task's edits stay in its folder and don't mix with another agent's.

The Hermes Review Desk Worktrees tab listing one worktree each for orders-api, web-app and orders-types, with branch, task, age and disk size
The Review Desk's Worktrees tab: one Hermes worktree per task, each with its hermes/ branch, task, age and disk size.

Every agent turn is saved, so you can diff or restore a single turn, with undo. The Review Desk (⌘G, or Ctrl+Shift+G on Windows and Linux) shows changes by turn or by file, with risk flags. If turn 3 of a refactor quietly added a server to the repo's .mcp.json, you see that turn's diff on its own and can roll back that one turn. Keep one blind spot in mind: the Review Desk works from what git shows (staged, unstaged and untracked files), so a git-ignored folder such as a typical .vs\ is outside it. Check those paths by hand.

The Review Desk in By turn view, two turns listed with file and line counts, the first turn's diff open and a commit message drafted from the turns
The Review Desk in By turn view: two turns for a Claude Code task, turn 1's diff of src/db.ts, and a commit message drafted from the turns.

To check a cloned repo's agent config before an agent opens it, here's a six-file audit.

Hermes can run GitHub Copilot CLI as one of its agents. That is a different product from Copilot in Visual Studio, and nothing in this CVE says it is affected. Hermes is free to download, source-available under BSL 1.1, runs on macOS, Windows and Linux, and isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it or read the code on GitHub.

If this was useful

This bug lived in the gap between where a decision was made and where it took effect, and that is a design question before it is a line of code. If you like reasoning about where a check should sit in a system, the System Design Pocket Guide: Interviews walks through 15 real system designs, step by step. It's one of 8 short books in the Pocket Guides series, covering agents, RAG, prompts, observability, events, databases and system design.

System Design Pocket Guide: Interviews — 15 Real System Designs, Step by Step

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.