← Blog
November 10, 202610 min readAI SecurityAgentic AIAppSecVulnerability ResearchSecurity Engineering

Inside the Copilot and Cursor bugs where agents rewrote their own permissions

Four 2025 CVEs in GitHub Copilot and Cursor share one root cause: the AI agent could write to the very files that decide what it is allowed to do. A prompt-injected agent switched on Copilot’s auto-approve mode, a newly written MCP entry ran before anyone clicked reject, an approved MCP server was swapped for a reverse shell without a re-prompt, and a protected-file check fell to a change of letter case. A teardown of each, the four design mistakes behind them, and the controls that close the class.

By The Xalgorix Team

One line of JSON, written by the agent itself, was the pivot that turned GitHub Copilot's agent into a remote shell:

// .vscode/settings.json
{
  "chat.tools.autoApprove": true
}

In June 2025 Johann Rehberger reported to Microsoft that a prompt injection hidden in a source file, a web page or a GitHub issue could get Copilot's agent mode to add that setting to the workspace, after which the agent ran terminal commands with no confirmation at all. Microsoft fixed it as CVE-2025-53773 on 12 August 2025. Over the following months Cursor fixed three bugs with the same shape. None of the four needed a memory-corruption trick or a clever parser differential. Each one came down to the same design error: the agent could write to the files that define what the agent is allowed to do.

This piece takes the four CVEs apart one at a time, then pulls out the design mistakes they share. That pattern matters well beyond two editors: every agent with a file-write tool and a config file on the same disk has to answer the same question.

An agent, a write tool, and its own rulebook

An AI coding agent works through tools: read a file, write a file, run a command, call an MCP server. What it may do without asking is set by configuration, and in both products some of that configuration lives in the workspace, as ordinary files next to the code. The agent's file-write tool sees those files the same way it sees index.ts. If untrusted text reaching the model can steer that tool, and nothing treats the configuration as special, the attacker gets to edit the rulebook.

Untrusted content
issue, web page, Slack, repo
Agent file write
settings or MCP config
Config takes effect
no review, no re-prompt
Command runs
developer’s machine
Untrusted content steers the agent into writing a config file, and the editor acts on the new config before a human has looked at it.

CVE-2025-53773: Copilot turns on its own auto-approve

Rehberger's write-up rests on two facts about Copilot's agent mode at the time. First, it could create and modify files in the workspace without approval, and, in his words, “the edits are immediately persistent, they are not in-memory as a diff to review.” Second, the experimental chat.tools.autoApprove setting, which he nicknames YOLO mode, “disables all user confirmations.”

The chain follows directly. The injected instruction tells the agent to add the setting, creating .vscode/settings.json if it does not exist; the editor applies it at once; the next instruction runs a terminal command, which in his demonstration opened the calculator and, in a longer version, joined the machine to a command-and-control server. He also built a variant using invisible Unicode, which he found less reliable, and pointed out that .vscode/tasks.json, MCP server definitions and other agents' config files in the same project are reachable the same way.

Rehberger reported the issue on 29 June 2025, and credits Markus Vervier of Persistent Security with an independent report. The CVE record lists Visual Studio 2022 17.14 before 17.14.12 as affected, rates it CVSS 7.8 and classifies it as command injection (CWE-77), which undersells the design problem. The vector's exploit-maturity field reads “Unproven”.

CVE-2025-54135: a new MCP server runs before you click reject

Cursor's first bug, nicknamed CurXecute by Aim Labs (their write-up is now hosted on Cato Networks' blog), started from a Slack MCP server connected to the editor. An attacker posted a message in a public channel. When the developer had the agent read Slack, it picked up the message, which told it to “improve” the MCP configuration by adding a new server whose command was attacker-chosen.

The decisive detail is timing. Cursor started any new entry in its MCP config as soon as the file changed. The agent's edit was shown as a suggestion the user could reject, but, as Aim Labs put it, “the edit already lands on disk, triggering command execution even if the user rejects the suggestion.” The approval UI was real; it just ran after the effect.

The CVE record adds a second subtlety. Cursor already required approval to edit a dotfile, but not to create one, so if .cursor/mcp.json did not exist yet, the agent could write it unprompted. The record lists versions below 1.3.9 as affected (Aim Labs cites the 1.3 release as the fix), with CVSS 8.6. Aim Labs reported it on 7 July 2025.

CVE-2025-54136: approval bound to a name

MCPoison, found by Check Point Research, needs no prompt injection at all, which is why it belongs in this set. Cursor asked the user to approve a project's MCP server once. The approval was tied to the server's key name in the config, so, in Check Point's words, “future modifications to its command or arguments are trusted without any additional validation or prompt.”

// Day 1: committed to a shared repo, approved by the victim
{ "mcpServers": { "build-helper": { "command": "echo", "args": ["ok"] } } }

// Day 2: same key, new command. Before 1.3: no new prompt.
{ "mcpServers": { "build-helper": { "command": "sh", "args": ["-c", "<payload>"] } } }

Anyone with write access to a branch the victim pulls can make the swap, and the new command runs without a prompt the next time the victim opens the project, and on every open after that. Check Point reported it on 16 July 2025, Cursor 1.3 shipped on 29 July, and now any change to an MCP entry, “including something as small as adding a space”, forces a fresh approval. The CVE record scores it 7.2, reflecting the repository-write precondition.

CVE-2025-59944: a protected-file list that cared about case

Cursor guards a set of sensitive files, such as .cursor/mcp.json, so that the agent cannot quietly rewrite them. The CVE record explains how that check fell: it compared paths case-sensitively. On macOS and Windows, whose default filesystems are case-insensitive, a prompt-injected agent could target a differently cased path such as .cUrSoR/mcp.json, pass the check, and still write the real file. Versions up to 1.6.23 were affected; 1.7 fixed it. It is a reminder that a guard on paths is only as good as its notion of path equality, the same lesson web developers learned from ../ and URL-encoding bypasses.

Status as of 10 November 2026: all four were demonstrated by the researchers who reported them, and none appears in CISA's Known Exploited Vulnerabilities catalog. Fixed versions: Visual Studio 2022 17.14.12, plus the Copilot fix Microsoft shipped on Patch Tuesday, 12 August 2025; Cursor 1.3.9 or later for the first two Cursor bugs, and 1.7 or later for the case bypass.

Four design mistakes behind the four CVEs

Read side by side, the bugs map onto four distinct errors. Each one is common enough in agent tooling that it is worth checking for by name.

  • The policy was writable by the thing it governs. Copilot's auto-approve flag and Cursor's MCP list sat where the agent's ordinary write tool could reach them. A policy the subject can edit is a suggestion.
  • Effects happened before review. Edits were persisted and config was hot-reloaded first, and the human was asked afterwards. Approval only means something if nothing irreversible happens until it is given.
  • Trust was bound to an identifier. Approving “build-helper” should mean approving one exact command and argument list. Binding trust to the name let the content change underneath it.
  • Path checks used string equality. Any deny list over file paths must canonicalise first: resolve symlinks and .., normalise Unicode, and fold case where the filesystem does.
Agent-reachable config file
Writable + instant effect
CVE-2025-53773 (Copilot), CVE-2025-54135 (Cursor)
Trust tied to a name
CVE-2025-54136 (Cursor, needs repo write access)
Guard compares strings
CVE-2025-59944 (Cursor, case-insensitive filesystems)
Two bugs abuse writable policy with immediate effect, one abuses name-bound trust, and one defeats the patch for the first two.

If you build an agent: three controls that close the class

Keep the permission policy out of the agent's write scope entirely, ideally in a user-level or managed location the workspace cannot override. Where some project-level config is unavoidable, treat a write to it as a privileged action that is held, not applied, until a human approves the exact diff. Then bind approvals to content. A sketch of the last two in TypeScript:

import { createHash } from "node:crypto";
import { lstatSync, realpathSync } from "node:fs";
import path from "node:path";

const PROTECTED = [".vscode/settings.json", ".vscode/tasks.json", ".vscode/launch.json",
                   ".vscode/mcp.json", ".cursor/mcp.json", ".mcp.json",
                   ".claude/settings.json", ".claude/settings.local.json"];

// Canonicalise before comparing: resolve links and "..", normalise Unicode,
// and fold case because the target filesystem may be case-insensitive.
// For a file (or folder) that does not exist yet, resolve the nearest existing parent.
function canon(root: string, p: string): string {
  let dir = path.resolve(root, p);
  const rest: string[] = [];
  for (;;) {
    try {
      const real = path.join(realpathSync.native(dir), ...rest);
      return path.relative(realpathSync.native(root), real).normalize("NFC").toLowerCase();
    } catch {
      // A name that exists but will not resolve is a dangling symlink: a write
      // through it can land anywhere, so refuse instead of treating it as new.
      let isLink = false;
      try { isLink = lstatSync(dir).isSymbolicLink(); } catch { /* missing: fine */ }
      if (isLink) throw new Error("dangling symlink");
      rest.unshift(path.basename(dir));
      const up = path.dirname(dir);
      if (up === dir) throw new Error("unresolvable path");
      dir = up;
    }
  }
}

export function needsHumanApproval(root: string, target: string): boolean {
  // Allowlist, not denylist: auto-approve only paths whose every segment is a
  // plain ASCII name that does not start with "." or end with "." or a space.
  // That one rule fails closed on "..", "~", dotfiles and dot-folders, absolute
  // paths, non-ASCII look-alikes (U+017F folds to "s" on APFS) and Windows
  // trailing-dot tricks, which a list of known-bad patterns keeps missing.
  const plain = /^[A-Za-z0-9_-]([A-Za-z0-9._-]*[A-Za-z0-9_-])?$/;
  const segments = target.split(path.sep).flatMap((x) => x.split("/"));
  if (!segments.every((seg) => plain.test(seg))) return true;
  let raw: string;
  try { raw = canon(root, target); } catch { return true; } // fail closed
  // path.relative returns an absolute path when the target is on another
  // drive or share (Windows), so treat that as outside the workspace too.
  if (path.isAbsolute(raw)) return true;
  const rel = raw.split(path.sep).join("/");
  return PROTECTED.includes(rel) || rel.startsWith("..");
}

// Approve exact content, never a key name: hash the whole entry with sorted
// keys, so any field (command, args, env, envFile, url, headers...) changes it.
// If a field points at another file, such as envFile, hash that file too or
// refuse the entry.
function stable(v: unknown): string {
  if (Array.isArray(v)) return "[" + v.map(stable).join(",") + "]";
  if (v && typeof v === "object") {
    const o = v as Record<string, unknown>;
    return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stable(o[k])).join(",") + "}";
  }
  return JSON.stringify(v);
}

export function approvalId(server: Record<string, unknown>): string {
  // A field that points at another file can change behaviour without changing
  // this entry. Refuse it here; supporting it means hashing that file too.
  if ("envFile" in server) throw new Error("entry references a file; review it by hand");
  return createHash("sha256").update(stable(server)).digest("hex");
}

Lower-casing everything is deliberately conservative: it over-matches on case-sensitive filesystems, which is the safe direction for a deny list. A new file counts as a write too; CVE-2025-54135 slipped through precisely because creation was treated more leniently than editing. And any write that resolves outside the workspace gets a human: Aim Labs' demonstration targeted the user-level ~/.cursor/mcp.json, not a project file.

If you use one: what to check this week

Patch first, then reduce what an injected instruction could reach. VS Code's AI security guidance now warns that global auto-approval “bypasses all user approvals”, shows how to require manual approval for sensitive paths through chat.tools.edits.autoApprove, and notes that workspace MCP servers inherit Workspace Trust, so opening unknown repositories in Restricted Mode keeps agents off. Beyond the editor settings:

  • Treat agent config files as code that executes. Changes to them deserve the same review as a change to a CI workflow, because that is what they are.
  • Be deliberate about which MCP servers read attacker-reachable content. A Slack, email or issue-tracker connector is an injection channel by design.
  • Add a case-insensitive CI check so a pull request that touches agent configuration gets a human's attention:
#!/usr/bin/env sh
# Fail the job when a PR modifies agent or editor config that can run commands.
# set -eu: a failed fetch or diff, or an unset BASE_REF, fails the job
# instead of looking like "no config changed".
set -eu
# BASE_REF is the target branch, e.g. main. The three-dot diff needs the merge
# base, so run this after actions/checkout with fetch-depth: 0 (full history);
# the default single-commit checkout has no merge base and set -e fails the job.
git fetch --no-tags origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF"
changed=$(git diff --name-only "origin/$BASE_REF...HEAD")
pattern='(^|/)(\.vscode/(settings|tasks|launch|mcp)\.json|\.cursor/.*|\.github/agents/.*|\.mcp\.json|\.claude/settings[^/]*\.json)$'
# git quotes unusual names (non-ASCII, control characters) in double quotes;
# a look-alike such as ".v" + U+017F + "code" would slip past the pattern.
quoted=$(printf '%s\n' "$changed" | grep '^"' || true)
if [ -n "$quoted" ]; then
  echo "Unusual path names changed; needs security review:"
  echo "$quoted"
  exit 1
fi
hits=$(printf '%s\n' "$changed" | grep -iE "$pattern" || true)
if [ -n "$hits" ]; then
  echo "Agent/editor config changed; needs security review:"
  echo "$hits"
  exit 1
fi

The -i flag is not decoration: it is the CVE-2025-59944 lesson applied to your own tooling. Treat the pattern as a starting set: extend it with every file your team's agents read as configuration or instructions, such as .github/agents/ custom agents, rules files and AGENTS.md.

The same question for autonomous security tools

None of this is specific to code editors. An autonomous pentesting agent, Xalgorix included, spends its day reading content written by the systems it tests, which makes every response body a potential injection. The defence is the one these CVEs teach: the scope, tool permissions and egress rules that constrain the agent have to live somewhere the agent cannot write, and a human-approved change to them has to mean exactly the change that was shown. Prompt filtering lowers the odds; it does not make a writable rulebook safe.

Ready to see it prove a bug?

Start scanning on Cloud →