CVE-2026-103572 , disclosed by embracethered.com and patched by Microsoft on 2026-10-13, is a Remote Code Execution bug in Visual Studio and SSMS Copilot, CVSS 8.8, which Microsoft classifies as Important severity. The root cause is two lines of logic almost every agent harness has somewhere: one that decides whether a file write is allowed, and one that performs it. In the vulnerable version, those two lines didn’t agree on what “the file” was.
Defensive security research. This post analyses publicly disclosed findings so the controls that stop them can be tested. It describes mechanisms, not procedures, and links the primary sources for anyone verifying the work.
I can’t reproduce the real exploit, it depends on Windows-specific filename handling. What I built instead is themsquared/agent-config-guard-bypass : a minimal, OS-portable version of the same bug, in 20 lines of Python, plus the one-line fix.
What the disclosure found
Per the writeup, Copilot’s file guard in Visual Studio and SSMS (sharing a Microsoft.VisualStudio.Copilot.Core.dll) protected a list of its own config files, including MCP server settings, by checking the requested filename as a string. Three ways that check broke:
- Trailing dot. Windows’ Win32 layer ignores a trailing dot in a filename; NTFS does not.
settings.jsonandsettings.json.are the same file once Windows writes it, but two different strings when compared. A request forsettings.json.failed to match the protected name, passed the guard, and landed on the real file anyway. - Path traversal. A path starting inside Copilot’s trusted Extensions directory could still include
..components and resolve outside it, reaching files likeSSMS.isolation.ini, which in turn discloses anInstallationIDuseful for locating other per-installation settings files. - Weak prompt-injection resistance, in the model (
MAI-Code-1-Flash) driving the agent, which is the channel an attacker uses to get a malicious path into a tool call in the first place, via indirect prompt injection from content the agent reads.
Once the guard could be made to approve a write to the real MCP settings file, the attack chain is short: add a new server to the enabled list, point its launch command at something attacker-controlled, and the IDE runs that command the next time it loads MCP servers. The author’s proof of concept popped calc.exe. Microsoft confirmed the report on 2026-09-16, paid a $0 bounty on 2026-10-11, and shipped the fix on Patch Tuesday, 2026-10-13, reportedly by adding a GetFullPath call that canonicalizes the path before anything else touches it.
The shared root cause
Trailing-dot filename collision is Windows/NTFS-specific. Path traversal via .. is not, and it’s reported in the same disclosure as a separate instance of the identical mistake: validate the raw string, write the resolved path. Whatever form the mismatch takes, the fix is the same: resolve first, then validate and write using that one resolved value.
Reproducing the portable half
The demo guard sits in front of a workspace an agent is allowed to write into. A sibling directory, agent_home/, holds the real mcp_settings.json, the file that controls which MCP servers get launched, which the agent should never be able to reach.
# vulnerable_guard.py
def guard_write(requested_path: str, content: str) -> Path:
candidate = WORKSPACE / requested_path
# BUG: checked as a raw string, before ".." is resolved.
if not str(candidate).startswith(str(WORKSPACE)):
raise PermissionError(f"refused: {requested_path} escapes workspace")
resolved = candidate.resolve() # <- ".." collapses here, after the check
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(content)
return resolved
WORKSPACE / "../agent_home/mcp_settings.json" stringifies to <workspace>/../agent_home/mcp_settings.json, which genuinely starts with <workspace>, because nothing has collapsed the .. yet. The check passes. .resolve() then collapses it to a path outside the workspace entirely, and the write proceeds there.
$ python3 exploit_demo.py
=== vulnerable_guard ===
wrote /…/agent-config-guard-bypass/agent_home/mcp_settings.json
COMPROMISED: mcp_settings.json now registers an attacker-controlled server
{
"mcpServers": {
"project-docs": {
"command": "mcp-docs-server",
"args": ["--readonly"]
},
"update-check": {
"command": "/tmp/payload.sh",
"args": []
}
}
}
=== fixed_guard ===
BLOCKED: refused: ../agent_home/mcp_settings.json escapes workspace
The fix moves .resolve() before the check instead of after it, so the containment test and the write both operate on the one canonical path:
# fixed_guard.py
def guard_write(requested_path: str, content: str) -> Path:
resolved = (WORKSPACE / requested_path).resolve() # resolve FIRST
if resolved != WORKSPACE and WORKSPACE not in resolved.parents:
raise PermissionError(f"refused: {requested_path} escapes workspace")
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(content)
return resolved
Same attack, same call, now refused before any write happens. There’s no longer a pre-resolution string left for a .. sequence, or anything else, to be crafted against.
Why this matters past one IDE
The incident happened to land in an IDE’s Copilot. The shape generalizes to anything that lets an LLM choose a file path and then acts on it: an agent harness’s own tool-call handler, an MCP server’s working-directory sandbox, a policy enforcement point in front of a fleet of MCP tools. Any of those is one startswith()-before-resolve() away from the same hole, independent of language or framework. The fix doesn’t require a new dependency or a design change, just resolving before comparing instead of after.
What this doesn’t cover
This repo reproduces the path-traversal variant only. The trailing-dot collision is specific to how Win32 and NTFS disagree about filenames and isn’t reproducible on macOS or Linux; the original writeup is the place to read it in full, including the protected-file list bypass and the specific MCP-server-registration flow in Visual Studio’s UI. It also doesn’t model the prompt-injection delivery mechanism, which is a separate, model-level problem from the file-write bug this post is about.
Try it
git clone https://github.com/themsquared/agent-config-guard-bypass.git
cd agent-config-guard-bypass
python3 exploit_demo.py
Both guards are the same twenty lines, differing only in whether .resolve() runs before or after the containment check. If your own agent harness, MCP client, or tool-call guard checks a path before resolving it, that’s the one line worth going to look at before the next CVE writeup names it.