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:
- 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.
- 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. - Self-permission. The agent calls its own
write_filetool, 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. - 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:
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.