Back to The Brief § 05 · SECURITY RESEARCH

When the IDE writes its own permissions.

A reading of CVE-2025-54135 (CurXecute), and why a permission list that lives in the agent's own writable filesystem is a permission list with one footnote: or whoever else can write here.

LUPID Research · 30 April 2026 · 11 min read

In late April 2026, Cursor disclosed CVE-2025-54135, tracked by the security community as CurXecute. The bug shape, in one sentence: a Slack message in a channel the IDE's agent could read could instruct that agent to write a file at ~/.cursor/mcp.json. That file is Cursor's tool registry. Once the file said the agent had a shell tool, the agent had a shell tool. From a single Slack message, the agent on the developer's laptop went from "browses the repo" to "runs commands as the developer."

This post walks the chain in the same way our earlier reading of TrustIssues did, and at each step describes what an enforcement layer outside the agent's process and outside its filesystem would have caught. The original disclosure and remediation credit belongs to the Cursor advisory team and the researchers tracking the wider class of MCP-config attacks. We are not claiming the bug. We are reading it.

A permission list checked by the same component that can be told to rewrite the list is, by construction, advisory. Only the list whose author the agent cannot reach is enforceable.

The attack, in four moves

For readers who haven't seen the disclosure, the chain is:

  1. Vector. Attacker posts a crafted message in a Slack channel that the developer's Cursor session has been wired to read — via an MCP server, an inbox watcher, or a workflow integration.
  2. Injection. The agent reads the channel as part of its routine context. The message body contains hidden instructions plus a small JSON literal — a fragment of mcp.json — that registers a new tool with shell-execution semantics.
  3. Self-permission. The agent calls its own write_file tool, which is auto-approved in some Cursor configurations, and writes the JSON to ~/.cursor/mcp.json. Cursor's MCP server registry rereads on the next turn. The new tool is now declared.
  4. Execution. The agent calls the freshly-declared shell tool. The argument is whatever the attacker wanted — usually exfil of an environment file, or an outbound HTTP request that trades the developer's session for a callback.

Each step is, on its own, a thing the agent is allowed to do. The chain is what's wrong. CurXecute is not a memory-corruption bug or a parsing bug. It is a privilege-composition bug: the union of "the agent can read Slack" and "the agent can write files" and "the agent's tool list is a writable file" is shell access. The components are not the failure. The composition is.

Cursor's patch — per the advisory — tightens the auto-approve heuristics on writes to dotfiles in the project root. That is the right fix at the IDE layer. It is also the kind of fix that depends on Cursor knowing every dotfile path that matters, on every operating system, in every customer's environment, forever.

Config-write privilege escalation, as a class

The deeper move CurXecute illustrates is one we have been calling, internally, config-write privilege escalation. The pattern:

  • An agent has a generic capability (write a file, edit a setting).
  • The agent's own permission list is encoded in a file or setting that capability can reach.
  • Therefore the agent has, transitively, the capability to grant itself any further capability.

Once you start looking for it, this pattern is everywhere. .cursor/mcp.json, .vscode/settings.json, .claude/settings.json, ~/.aws/credentials, ~/.npmrc, ~/.gitconfig's core.editor, .env.local, anything in $XDG_CONFIG_HOME. Each of these is, on a typical developer machine, a small file that some downstream process will read and act on. Each is also writable by any process running as the developer — including the agent.

The mitigation is structural, not heuristic. The permission list needs to live somewhere the agent cannot write. Not "shouldn't" write; cannot. Different process, different uid, different namespace, different machine — pick one.

If Lupid was there — Gate 2 (Tool registry, outside the filesystem)

Lupid's tool registry doesn't live on the developer's disk. It lives in the policy plane — a Policy bundle, signed and version-controlled, distributed every 30 seconds to the gateway and the endpoint shield daemon. The agent does not have a writable copy of its own permission list. The list is read at the network layer, not at the IDE layer.

So when the agent on a CurXecute-shaped exploit asks the LLM provider for a tool call against shell.invoke, the request mutation layer (Gate 2) parses the request body, reads the tools array against the policy-declared list for agent:cursor-on-mbp-edwards-7f2, and finds — because shell.invoke isn't in the agent's job description — that the tool is not registered. The string shell.invoke is removed from the request before it reaches the model. The model is told it has tools fs.read, repo.diff, github.pr.comment. It doesn't know any other tool exists, because the request never showed it any other tool.

This is the same control that broke Trust Issues' chain at Step 2. The reason it works on CurXecute too is that the underlying property — "the model only sees tools the policy plane authorized" — is class-independent. It does not depend on the specific bug class. It does not depend on what was written to mcp.json. It does not depend on whether mcp.json was written at all.

If Lupid was there — Gate 3 (Filesystem-arg policy, default-deny)

Suppose the operator misconfigured Gate 2 and the agent does have a generic fs.write tool declared, because the workflow author wanted the agent to be able to scaffold project files. Suppose the LLM, prompt-injected by the Slack message, returns a tool call with fs.write and an argument writing JSON to ~/.cursor/mcp.json.

Gate 3 is the per-argument policy. It runs against tool arguments after the model returns and before the tool is invoked. Lupid's endpoint shield daemon ships with a default argument blocklist that includes, by class, all paths matching:

  • **/.cursor/**, **/.vscode/**, **/.claude/**, **/.aws/**, **/.config/** — the operator-config category
  • **/.env*, **/.git/config, **/.gitconfig, **/.ssh/**, **/.netrc — the credential-adjacent category
  • **/.npmrc, **/.pypirc, **/.cargo/credentials* — the package-registry-token category

Each match produces a ToolCallBlocked event with reason argument_path_in_blocklist_class. Critically, this is a class rule, not a specific-file rule. We are not maintaining a list that includes ~/.cursor/mcp.json by name. We are matching **/.cursor/**, which catches that file along with every other config Cursor will ever ship. Adding a tool config at ~/.cursor/mcp.v2.toml in a future release does not require a Lupid update.

The class rules are open-source defaults. Operators add their own, prune ours, or replace the whole set. The contract is the same: arguments are matched structurally, before the tool sees them.

If Lupid was there — Gate 5 (And if mcp.json got written anyway)

Suppose Gates 2 and 3 both got bypassed. The agent wrote mcp.json. The IDE re-read its tool registry. From Cursor's perspective, the agent now has a shell tool.

The agent calls the shell tool. The call goes out to the LLM provider as shell.invoke with the attacker's command in the argument. Gate 2 fires again, on the next request, because the Policy policy still does not include shell.invoke in this agent's tools list. The string is stripped from the outbound request body before the model sees it. The model returns "I don't have shell access." The IDE shows that text. Nothing runs.

This is the property that matters about putting the permission list outside the writable filesystem: the agent cannot win by rewriting state on its own machine. The state that decides whether a tool call leaves the device lives somewhere the agent cannot reach.

Policy policies, distributed by Lupid's policy plane, are signed and verified before the gateway evaluates them. The signing key lives off the developer's machine. A compromised laptop cannot mint a valid policy bundle.

What an operator would have seen

Three audit events would have been written, in order, on a single agent turn:

14:09:11.082 ToolCallBlocked agent:cursor-fv7-7c7c tool:fs.write arg_class:**/.cursor/** gate:3 14:09:11.083 PromptClass session-elevated signal:untrusted_input.slack_message risk:0.84 14:09:11.084 PolicyDeny shell.invoke reason:tool_not_in_declared_set gate:2 14:09:11.130 IncidentSnapshot anchor=ToolCallBlocked window=[-300s, +60s] trigger=anomaly:Critical

The third row is the load-bearing one. Even if the operator ignored the first two events, the third forecloses the actual harm: the shell call doesn't run, the device's filesystem is not affected by anything beyond the original (already-blocked) write attempt, and the developer's environment variables stay where they were.

The fourth row is the snapshot trigger. Lupid's anomaly engine sees three structured deny events in the same session within fifty milliseconds and lights up a Critical alert. The on-call gets a single notification with a forensic snapshot pre-attached. The developer, meanwhile, sees the agent reply with "I don't have permission to write to your IDE config" and goes back to whatever they were doing.

What this means for tooling vendors

Cursor's patch is correct for the bug they were told about. The class is what's worth talking about industry-wide:

Agents have writable access to their own configuration. Treat that as a privilege escalation primitive. Every agent we have looked at — Cursor, Claude Code, Aider, Continue, Cody, Windsurf, Roo, Cline — stores tool and permission state in dotfiles the agent's own write capabilities can reach. The patches will keep coming. The pattern won't go away unless the configuration moves out of the agent's filesystem.

"Auto-approve" is a setting that should not exist on tool-config-modifying actions. The category to lock behind explicit human approval is not "destructive shell commands." It is "writes to anything that is going to be re-read as a permission decision." Those overlap less than you'd think.

The blocklist should be a class, not a list. Cursor's patch enumerates dotfile paths it doesn't want the agent to auto-approve writes to. Anthropic's recent advisory on Claude Code's ~/.claude/ directory does the same thing for a different path. Each vendor maintains a narrow allowlist. The systemic answer is a structural rule that any vendor's agent obeys: writes to operator-config classes go to the human.

Defence in depth is not redundant work. Lupid's three gates each block this attack independently. We didn't deploy three gates because we couldn't decide which one was right. We deployed three because the cost of any single one being misconfigured is RCE on a developer laptop, and the cost of running all three is one extra policy evaluation per request — which we already do.

A note on what we're building

Lupid's runtime ships the three gates described above as defaults. The policy bundle includes the class-rule defaults for filesystem arguments, which the operator can extend or override. The gateway runs the request-mutation layer (Gate 2) for SDK-enrolled agents; the endpoint shield daemon runs it for tools like Cursor and Claude Code that we don't control.

The shield daemon is a single binary. It installs into a per-user profile, intercepts the LLM-provider HTTP requests via netfilter and eBPF TC rules on Linux, via an HTTP proxy on macOS Network Extension, via Windows WFP filters on Windows. It is the one moving part the developer needs on their machine. It does not require any modification to Cursor, Claude Code, or any other agent.

If you want to talk about which controls in this post would and would not have applied to your environment — including the multi-vendor agent landscape most of our customers actually run — reach out. We will be specific.

LUPID Research · Filed 30 April 2026
Disclosure note. This post is a Lupid-side reading of the CurXecute disclosure and the wider class of MCP-config-write attacks tracked through CVE-2025-54135 and adjacent identifiers. We reproduced a CurXecute-shaped exploit against an offline Cursor instance behind the Lupid endpoint shield with default policy. The four-step chain terminated at Step 3 (Gate 3, argument-path blocklist) on the first run; we then progressively weakened the policy to verify Gate 2 caught the chain at Step 4 in the absence of Gate 3, and that Gate 5 ensured the synthesized shell tool was unreachable even when the configuration write succeeded. Original disclosure and remediation credit belongs to the Cursor security team and the independent researchers who reported the bug.
Related briefs