TL;DR
- What was reported: CVE-2026-103572 (Microsoft rates it Important, CVSS 8.8 per the researcher's write-up) is a path-validation bypass in GitHub Copilot inside Visual Studio and SSMS. The agent's "ask before editing a protected file" check compared filename strings. Windows silently drops a trailing dot, so
.mcp.json.passed the check and then wrote to.mcp.json. Microsoft shipped the fix on 2026-10-13. - Why it matters beyond one product:
.mcp.jsontells the IDE which commands to launch as MCP servers. An agent that can rewrite it, even after one indirect prompt injection, can get arbitrary command execution on the next launch. - What I measured: on a Windows 11 machine I tried five spellings of the same protected file against three gates. A string-match gate let all 4 working aliases through when the file existed. A "canonicalize with
realpath, then deny-list" gate caught all of them when the file existed, and still let 3 of 4 through when the file had not been created yet. An allowlist gate that also rejects alias syntax let 0 through. - The lesson: validate the path the operating system will open, and prefer "writes allowed only here" over "writes blocked there".
- Everything below, including the code and the raw output, was run before publishing. One machine, one Python version, stated plainly.
What does CVE-2026-103572 actually say?
I cross-checked three public write-ups: the researcher's own post on Embrace The Red, a Cloud Security Alliance research note, and an Adversa monthly roundup. They agree on the mechanism and the timeline.
| Field | What the sources say |
|---|---|
| Component | Microsoft.VisualStudio.Copilot.Core.dll, method EditsFunctionHandler.GetEditConfirmationTypeAsync |
| Reported to MSRC | 2026-08-04 |
| Confirmed by Microsoft | 2026-09-16 |
| Patched | 2026-10-13 (Patch Tuesday) |
| Severity | Important, RCE, CVSS 8.8 in the researcher's analysis |
| Protected files | .mcp.json, settings.VisualStudio.json, SSMS.isolation.ini |
| Fix | Call GetFullPath before validating, which strips the trailing dot and resolves .. segments |
| Fixed builds (CSA note) | 17.14.42 for VS 2022 17.14, 18.10.4 for VS 2026 18.10 |
| Exploitation | Needs indirect prompt injection (an opened file or MCP server output). No in-the-wild exploitation reported in the sources I read |
One caveat on scope: the sources differ on whether SSMS and Visual Studio 2022 appear on Microsoft's official affected list. I am not going to settle that here. If you run these products, read the vendor advisory for your build.
The timeline, from the dates above:
That is 43 days from report to confirmation and 27 more to the patch, 70 in total. It is a useful number if you rely on vendors to close agent-permission bugs for you: your own gate has to hold for weeks.
How does a trailing dot get past an edit gate?
The attack chain in the write-ups is short:
- Untrusted content (a file the agent opens, or text returned by an MCP server) carries a prompt injection.
- The agent proposes an edit to
.mcp.json.with a trailing dot. - The gate compares the string against its protected list.
.mcp.json.is not in the list, so no confirmation dialog appears. - The Win32 layer normalizes the name when the file is opened. The trailing dot is ignored, and the write lands on
.mcp.json. - The agent adds an attacker-defined stdio server, enables it, and the IDE launches the command.
Steps 3 and 4 disagree about what the file is called. The gate judged one string. The operating system acted on another. That gap, validate first and canonicalize later, is the whole bug, and it is not specific to Copilot or to Windows.
Which other spellings reach the same file?
I did not want to take the "one trailing dot" story on faith, so I tested the class. On my machine (Windows 11 build 26200, Python 3.12.10) I wrote the string INJECTED through each alias into a directory containing a protected .mcp.json, then checked whether the protected file changed.
import os, sys, tempfile PROTECTED = {".mcp.json", "settings.visualstudio.json", "ssms.isolation.ini"} SAFE_PREFIXES = ("src", "tests") # writes allowed without a human only here def naive_gate(root, rel): # string match on the name the model sent return os.path.basename(rel).lower() in PROTECTED def realpath_gate(root, rel): # "canonicalize" with realpath, then deny-list return os.path.basename(os.path.realpath(os.path.join(root, rel))).lower() in PROTECTED def hardened_gate(root, rel): # allowlist + reject names Win32 would rewrite name = os.path.basename(rel) if name != name.rstrip(" .") or ":" in name or "~" in name: return True full = os.path.abspath(os.path.join(root, rel)) top = os.path.relpath(full, root).split(os.sep)[0] inside = os.path.commonpath([os.path.abspath(root), full]) == os.path.abspath(root) return not (inside and top in SAFE_PREFIXES) # True means "ask a human" Each gate returns True when it would ask for confirmation. The harness evaluates the gate before performing the write. That ordering matters: my first version evaluated after the write, and the protected file then existed in both scenarios, which hid the gap described in the next section.
VARIANTS = [".mcp.json", ".mcp.json.", ".mcp.json ", ".mcp.json::$DATA", "MCP~1.JSO"] def run(protected_exists): for v in VARIANTS: root = tempfile.mkdtemp(prefix="gate_") target = os.path.join(root, ".mcp.json") if protected_exists: open(target, "w").write("ORIGINAL") verdict = (naive_gate(root, v), realpath_gate(root, v), hardened_gate(root, v)) try: open(os.path.join(root, v), "w").write("INJECTED") hit = os.path.exists(target) and open(target).read() == "INJECTED" except OSError: hit = False print(repr(v), verdict, hit) Raw output from my run:
protected file already exists = True name sent by agent naive realpath hardened write lands on .mcp.json '.mcp.json' ASK ASK ASK True '.mcp.json.' ALLOW ASK ASK True '.mcp.json ' ALLOW ASK ASK True '.mcp.json::$DATA' ALLOW ASK ASK True 'MCP~1.JSO' ALLOW ASK ASK True protected file already exists = False name sent by agent naive realpath hardened write lands on .mcp.json '.mcp.json' ASK ASK ASK True '.mcp.json.' ALLOW ALLOW ASK True '.mcp.json ' ALLOW ALLOW ASK True '.mcp.json::$DATA' ALLOW ALLOW ASK True 'MCP~1.JSO' ALLOW ALLOW ASK False Four aliases reached the protected file when it existed: a trailing dot, a trailing space, an NTFS default-stream suffix (::$DATA), and the 8.3 short name MCP~1.JSO. The trailing space surprised me. The short name only works as an alias while the long-named file exists; in the second scenario it simply creates a differently named file, so it never lands on .mcp.json.
Is canonicalizing with realpath enough?
No, and this was the part I did not expect. In the first scenario realpath resolved every alias to .mcp.json, so the deny-list caught them. In the second scenario, where .mcp.json did not exist yet, realpath returned the name unchanged:
'.mcp.json.' abspath -> '.mcp.json' realpath -> '.mcp.json.' '.mcp.json ' abspath -> '.mcp.json' realpath -> '.mcp.json ' os.path.abspath (which wraps the Win32 full-path call) strips the dot and the space. os.path.realpath in this Python version did not, for a path that does not exist. The operating system still normalized the name at write time, so creating .mcp.json. created .mcp.json. That is exactly the case that matters for config files an attacker wants to plant: the file that should never be created by an agent.
This is an observation about one Python build on one machine. Other languages and versions resolve paths differently, which is the point: you cannot assume your canonicalizer matches the one the kernel applies. Test it against the real filesystem.
Counting the eight alias attempts (four per scenario, excluding the exact name), the string match allowed 8, and 7 of those writes hit the protected file. The realpath deny-list allowed 4, and 3 hit. The allowlist gate allowed none.
What should an edit gate do instead?
Three rules fall out of the measurements.
1. Prefer an allowlist to a deny-list. The hardened gate does not try to enumerate every dangerous file. It says the agent may write without a human only under src/ and tests/, and everything else asks. A new config file, a new alias spelling, or a file nobody thought to list all land in the same place: a confirmation prompt. A deny-list has to be right about every spelling forever. An allowlist only has to be right about where safe writes live.
2. Reject names the filesystem would rewrite. A name that ends in a space or a dot, contains : (streams, drive prefixes), or contains ~ (short names) has no legitimate reason to come from a model editing source code. Rejecting that syntax before any path math removes a whole family of aliases. In my run this cost nothing: the hardened gate still allowed src/app.py.
3. Compare against what the OS will open, using the same canonicalizer. If you must keep a deny-list, resolve with the normalization the platform applies, and resolve the parent directory when the file does not exist. Then check again after resolving, immediately before the write, not when the model first proposed the edit.
A regression test is cheap. Put the aliases in CI so a refactor cannot quietly reopen them:
import pytest ALIASES = [".mcp.json.", ".mcp.json ", ".mcp.json..", ".mcp.json::$DATA"] @pytest.mark.skipif(sys.platform != "win32", reason="Win32 name normalization") @pytest.mark.parametrize("alias", ALIASES) @pytest.mark.parametrize("exists", [True, False]) def test_alias_requires_confirmation(tmp_path, alias, exists): if exists: (tmp_path / ".mcp.json").write_text("ORIGINAL") assert hardened_gate(str(tmp_path), alias) is True Is this only a Windows problem?
The trailing-dot mechanism is Win32. The class is wider: any time the check and the use see different representations of a path, there is a gap. Symlinks and directory junctions can redirect a "safe" path to a protected one. .. segments and encoded paths do the same in tools that accept them. Case-insensitive file systems and Unicode normalization add more spellings. I only tested Windows here, and I did not test reserved device names, junctions, or Unicode normalization, so treat those as items for your own test list, not as findings.
The Cloud Security Alliance note that covers this CVE also covers a second case with the same shape: an OAuth flaw in the opencode agent on Windows and WSL (versions 1.0.137 through 1.18.32, fixed in 1.18.33 on 2026-09-28 by upgrading the open package and quoting the target). A remote MCP server could advertise an authorization URL containing PowerShell subexpressions, and the browser launcher evaluated them. The URL was still valid HTTPS syntax, so a check that looked only at the scheme saw nothing wrong. Per that note, the vendor declined to issue a CVE, which means scanners cannot flag affected installs. Different bug, same root cause: the validator and the executor disagreed about what a string means.
What would I do on Monday?
- Treat agent-writable config as code. List every file that, when changed, changes what runs next: MCP configs, hook definitions, IDE settings, tool paths, CI definitions. Require human confirmation for any agent write to them.
- Switch the default from "block these" to "allow here". Start with the narrowest writable area that lets the agent do its job.
- Gate on the resolved path, immediately before the write. Not on the string in the model's tool call, and not minutes earlier in an approval prompt.
- Run the alias tests on every platform you ship. The Windows cases above are a start. Add your own for the filesystems your users run.
- Log both strings. Record the name the agent sent and the path you resolved. When they differ, that is a signal worth alerting on, whether or not the write was allowed.
FAQ
What is CVE-2026-103572 in one sentence?
A Copilot check that asked for confirmation before editing protected config files compared filename strings, so a trailing dot (.mcp.json.) skipped the prompt while Windows still wrote to .mcp.json, which could register an attacker's command as an MCP server.
Has it been patched?
Yes. Microsoft shipped the fix on 2026-10-13 by normalizing the path with GetFullPath before validation. The CSA note lists fixed builds 17.14.42 (VS 2022 17.14) and 18.10.4 (VS 2026 18.10). Check the vendor advisory for your exact build.
Was it exploited in the wild?
Not in the sources I read. Exploitation needs an indirect prompt injection that reaches the agent, such as an opened file or MCP server output.
Why did the string match fail but realpath mostly work?
When the protected file exists, realpath resolves the alias to the real name, so a deny-list matches. When the file does not exist yet, my Python build left the alias unresolved, and the deny-list missed it. The file you most want to keep an agent from creating is the one that does not exist yet.
Is an allowlist always better than a deny-list?
For write gates, in my testing, yes: it failed safe on every alias. It costs more confirmation prompts for writes outside the safe area, and you tune that by widening the safe area deliberately, not by growing a list of exceptions.
Further reading
Earlier posts in this series:
- Authenticated Is Not Authorized: What a 9.3 Fail-Open Default in an Agent Mesh Teaches About Tool Gateways (published 2026-11-02, same series)
- The Model Is Not a Security Boundary: What Two Semantic Kernel CVEs Teach About Gating Tool Arguments (published 2026-10-22, same series)
Sources
- Visual Studio Copilot and SSMS Remote Code Execution (CVE-2026-103572), Embrace The Red
- MCP Launch Paths as RCE: The opencode OAuth Flaw and Copilot CVE-2026-103572, Cloud Security Alliance Labs
- AI coding agent vulnerabilities, November 2026: Copilot RCE, Adversa AI
- Code and measurements in this post were run on 2026-11-04 (Windows 11 build 26200, Python 3.12.10).