cd /news/ai-tools/verified-publisher-is-not-a-trust-bo… · home › topics › ai-tools › article
[ARTICLE · art-167764] src=pub.towardsai.net ↗ pub= topic=ai-tools verified=true sentiment=↓ negative

“Verified Publisher” Is Not a Trust Boundary: What the Trojanized Claude Code Extension Teaches…

A tampered build of Anthropic's Claude Code extension was distributed through the Cursor VS Code Extension Marketplace for roughly eleven hours on 29 October, between about 2:27 am and 1:35 pm Pacific, affecting at most 5,000 users, according to Cursor's security notice. The injected code, described by OpenSourceMalware as a 66-line loader prepended to the legitimate extension, collected hostname, username, OS, environment variable names, Git email and public IP, then attempted to add an attacker SSH key to authorized_keys, enable local SSH and open a reverse SSH tunnel kept alive via a macOS LaunchAgent or a Windows scheduled task running as SYSTEM. Cursor says version 2.1.314 replaced the tampered 2.1.312 and 2.1.313 builds but does not clean machines that already ran them, and advises rotating credentials from a trusted system because uninstalling may not remove persistence scripts stored outside the extension folder.

by read21 min views3 publishedNov 11, 2026

For about eleven hours on 29 October, the extension marketplace inside Cursor handed out a tampered build of Anthropic.claude-code. It carried the right name and the right publisher, and it arrived through auto-update. Most people who got it never clicked anything.

I have spent the week since reading every primary statement I could find, and arguing with myself about one question: what did the “verified” badge on that listing actually tell anyone? My answer, and this is my opinion rather than anyone’s finding, is that a verified publisher identity tells you who uploaded something. It does not tell you which marketplace served it to you, and it says nothing about what the thing can reach once it is installed.

This article is about that gap. It starts with the incident, widens to the rest of a busy week for agent plugins, and ends with the policy I settled on for my own machines and the doubts I still have about it.

I am going to be careful here, because the attribution is contested and several of the early claims did not survive the week.

What Cursor says. Cursor’s security notice says a maliciously modified Claude Code extension was available in the Cursor VS Code Extension Marketplace between about 2:27 am and 1:35 pm Pacific on 29 October, which is roughly 09:27 to 20:35 UTC, and that at most 5,000 users were affected (Cursor’s own figure, a ceiling, not a count of machines that ran the payload). The notice was updated on 30 October and names versions 2.1.312 and 2.1.313 for macOS and Windows, not only 2.1.313, so if you only search for one version number you can miss a machine. Cursor also says extensions auto-update by default, which is how the build spread without anyone choosing it.

What the payload did. Per Cursor’s notice and the vendor write-ups from Sophos and others, the injected code ran when the extension loaded. It collected hostname, username, OS, the names (not the values) of environment variables, the configured Git email and the public IP. It then tried to add an attacker SSH key to authorized_keys, enable the local SSH service, open a reverse SSH tunnel, and keep that tunnel alive through a LaunchAgent on macOS or a scheduled task running as SYSTEM on Windows. Status went out as an HTTP beacon to port 8099. OpenSourceMalware's analysis describes the as 66 lines prepended to the legitimate extension code.

Cursor adds a limit worth repeating: enabling sshd needs admin rights the extension usually does not have, so the attacker probably could not log in to most machines. That is reassuring for a default laptop and much less so for a developer box that already runs sshd or has passwordless sudo.

Who saw it first. Phorion, an endpoint security vendor, is credited across the coverage with spotting the endpoint activity first, and its write-up is the most detailed account of the macOS persistence.

The fix that is not a fix. Version 2.1.314 replaced the tampered build. It does not clean a machine that already ran 2.1.312 or 2.1.313. Cursor says uninstalling may not be enough because the persistence scripts live outside the extension folder, and advises rotating credentials from a trusted system.

Indicators. The key comment svc-telemetry, the C2 address 134.209.82.225 and the VSIX hashes are published by Sophos, Cursor and Kevin Beaumont. Please take them from those primaries, not from this article. Advisories get corrected, and mine will not be.

+-----------------------------+--------------------------------------------------+| Party                       | Position (as published, paraphrased)             |+-----------------------------+--------------------------------------------------+| Anthropic                   | "A malicious actor used access to Cursor's       ||                             | marketplace" to publish a tampered extension.    ||                             | Anthropic was not involved in building,          ||                             | publishing or hosting it. A staff member added   ||                             | that it was not a compromise of Anthropic's      ||                             | systems or employee laptops.                     |+-----------------------------+--------------------------------------------------+| Cursor                      | The malicious versions were not developed by     ||                             | Anthropic. Cursor removed them, shut the         ||                             | marketplace down, rebuilt it, audited access     ||                             | and found no other tampered extensions. It has   ||                             | not said publicly how the build got in.          |+-----------------------------+--------------------------------------------------+| Kevin Beaumont, Phorion     | The build went out under Anthropic's official    ||                             | (verified) publisher account on Cursor.          |+-----------------------------+--------------------------------------------------+

Beaumont’s post has been edited since it went up; its headline now says the attack went through Cursor. Two of his other claims are still disputed: that the release was “silently deleted”, and that Microsoft’s VS Marketplace was also affected. BleepingComputer reports that Cursor turned out to be the only source distributing the malicious build. OpenSourceMalware’s addendum lists the Open VSX extension, the VS Marketplace extension and the npm package as not malicious. Socket says it found no publication record for 2.1.313 on Open VSX at all. So I am not going to tell you where the build came from upstream of Cursor, because nobody has shown it.

You may have seen a figure like “40 million developers” in aggregator posts. I have not found a primary for it. The only number with a named owner is Cursor’s “at most 5,000”.

Every layer in that chain did its job as designed, and the build still reached thousands of machines. That is the part that bothers me.

+------------------+-------------------------------------+-----------------------------------------+| Layer            | What a "verified" mark proves       | What it does not prove                  |+------------------+-------------------------------------+-----------------------------------------+| Marketplace      | The listing exists in this store    | That this store's copy matches the      ||                  | and passed its intake checks        | publisher's copy in other stores        |+------------------+-------------------------------------+-----------------------------------------+| Publisher acct   | The namespace is tied to a known    | Who actually holds upload access today, ||                  | organisation                        | or that this release came from them     |+------------------+-------------------------------------+-----------------------------------------+| Auto-update      | You get the newest version quickly  | That anyone reviewed it before it ran   ||                  |                                     | on your machine                         |+------------------+-------------------------------------+-----------------------------------------+| Extension runtime| Nothing; the editor runs it         | Any limit on file, process or network   ||                  | with your user rights               | access                                  |+------------------+-------------------------------------+-----------------------------------------+| Agent plugin     | It came from a marketplace your     | What its hooks, skills or mods do once  ||                  | settings allow                      | loaded in the harness process           |+------------------+-------------------------------------+-----------------------------------------+| MCP connector    | An admin or IdP approved the        | What scopes the token carries, or what  ||                  | connector for your group            | the server does with them               |+------------------+-------------------------------------+-----------------------------------------+

Read the right-hand column top to bottom and you get my thesis. The badge is an identity claim about one upload event. Distribution, update timing, runtime reach and token scope are separate questions, and the badge answers none of them.

Claude Code mods went on by default in v2.1.287, according to the official mods admin page. The docs are blunt about it: a mod is a plugin that runs code inside Claude Code with the permissions of the user who installed it, and mods are not sandboxed. The docs also say a user mod that approves a tool call can approve calls an ask rule would have prompted for, and that in auto mode a mod-approved call skips the classifier check.

Wasowski’s piece on mods goes further and argues mods sit outside deny rules and the sandbox entirely. The official wording I found is narrower (“other permission checks can be overridden”), so I would treat “outside deny rules” as his reading and check it against the version you run. Either way, the practical point stands: once a mod loads, the harness’s own rules are not the boundary.

The managed settings he lists do exist in the docs: allowManagedModsOnly (an option on the built-in guard under pluginConfigs), strictKnownMarketplaces and disableSideloadFlags. The version numbers he attaches to each I have not verified, so I left them out. Here is how I would wire the three together in managed-settings.json:

{  "strictKnownMarketplaces": [    { "source": "github", "repo": "anthropics/claude-plugins-official" }  ],  "disableSideloadFlags": true,  "pluginConfigs": {    "cc-plugin-sec-default@builtin": {      "options": { "allowManagedModsOnly": true }    }  }}

Add your own internal marketplace to the strictKnownMarketplaces array next to the official one. Managed settings files live at /Library/Application Support/ClaudeCode/managed-settings.json on macOS, /etc/claude-code/managed-settings.json on Linux and WSL, and C:\ProgramData\ClaudeCode\managed-settings.json on Windows, or come from the claude.ai admin console.

Postman’s own docs describe a one-command installer (npx @postman/postman-plugin) that installs its plugin into every coding agent it detects on the machine, plus status and remove commands. That is documented behaviour, and it is convenient.

The claim going around this week is different: that the Postman desktop app installed the plugin into Claude Code and Codex without asking. A secondary write-up points to a GitHub issue on Postman’s support repo, but I could not open the incident primary and I have not seen a Postman statement, so treat this as reported, not confirmed. What I take from it does not depend on the details: an app you installed for one job can write into the config of an agent you use for another, and neither the app’s badge nor the agent’s notices whether that write was expected.

If you run Postman, npx @postman/postman-plugin status tells you what it thinks it has installed.

REA (“Reverse Engineer Anything”) went viral in October. One setup walkthrough shows its installer registering an MCP server in ~/.claude.json and dropping a skill into ~/.claude/skills/, with a --dry-run flag that prints the planned changes first. Popularity brought look-alike copies, and people tracking the project are warning users to install only from the official repo and its own npm package. I am not going to link copies I have not analysed. The lesson is the same as with the extension: a familiar name on a repo or package tells you very little about which copy you are getting.

If you try it, run the dry run and read the planned actions before you let it touch your agent config.

JIN’s “Reach Ladder” piece sorts plugins by what they can reach: prompt context, then files, then shell, then network, then credentials and cloud APIs. I find the framing useful. I am not using its per-plugin details, because the piece reads like an unedited AI rewrite and I could not trace its facts. When I wanted a concrete rung, I went to a README instead.

HashiCorp’s Terraform MCP server reference is a good example of a plugin that documents its own ladder. The registry-private and terraform toolsets only register when you supply a valid TFE_TOKEN, and destructive tools such as action_run, delete_workspace_safely and force_unlock_workspace need ENABLE_TF_OPERATIONS=true as well. Same server, three very different reaches, chosen by environment variables.

This is also where the self-hosted option fits. Rather than a hosted endpoint, I would run it locally in Docker with no token and destructive operations off, so it can read the public Terraform Registry and nothing else. Without a token, the private-registry and workspace toolsets never register:

claude mcp add terraform -s user -- \  docker run -i --rm \  -e ENABLE_TF_OPERATIONS=false \  hashicorp/terraform-mcp-server:1.0.0

Pin the image by digest once you have pulled it, and only add TFE_TOKEN (scoped to one workspace) when you actually need that rung.

Anthropic’s Enterprise-Managed Authorization lets an admin connect the identity provider and choose connectors for the organisation, so they are already there when an employee signs in, across Claude chat, Claude Code and Cowork. It removes the per-user OAuth prompt.

I like this, and I also want to be honest about what it changes. That OAuth prompt was the one moment a user looked at a scope list. With EMA, the decision about what an agent can reach sits with whoever maps connectors to IdP groups. That is probably the right place for it, but it means the admin’s review is now the only review. A connector approved for “Engineering” reaches every agent session of everyone in that group.

The allowlist is one publisher.name@version per line. Anything installed that is not on it, any version that does not match, and any allowlist line without a version all fail the check. Unpinned entries fail on purpose: "allowed at any version" is how auto-update became the delivery path.

extensions.allowlist:

Bash, for macOS and Linux:

#!/usr/bin/env bash# check-extensions.sh: compare installed editor extensions with a pinned allowlist.# Usage: ./check-extensions.sh [editor-cli] [allowlist-file]#   editor-cli defaults to "cursor"; "code", "codium", "windsurf" work the same way.# Exit codes: 0 = all pinned and matching, 1 = drift found, 2 = setup problem.set -euo pipefailEDITOR_CLI="${1:-cursor}"ALLOWLIST="${2:-extensions.allowlist}"command -v "$EDITOR_CLI" >/dev/null 2>&1 || { echo "CLI '$EDITOR_CLI' not found on PATH" >&2; exit 2; }[ -f "$ALLOWLIST" ] || { echo "Allowlist '$ALLOWLIST' not found" >&2; exit 2; }# awk instead of bash 4 associative arrays, because macOS still ships bash 3.2.if "$EDITOR_CLI" --list-extensions --show-versions | awk -v allow="$ALLOWLIST" 'BEGIN {  fail = 0  while ((getline line < allow) > 0) {    sub(/#.*/, "", line); gsub(/[[:space:]]/, "", line)    if (line == "") continue    n = index(line, "@")    if (n <= 1 || n == length(line)) { print "UNPINNED  " line; fail = 1; continue }    pin[tolower(substr(line, 1, n - 1))] = substr(line, n + 1)  }  close(allow)}NF {  n = index($0, "@")  if (n == 0) { print "NOVERSION " $0; fail = 1; next }  id = tolower(substr($0, 1, n - 1)); ver = substr($0, n + 1)  if (!(id in pin))       { print "UNKNOWN   " $0; fail = 1 }  else if (pin[id] != ver) { print "MISMATCH  " id "  installed=" ver "  pinned=" pin[id]; fail = 1 }  else                     { print "OK        " $0 }}END { exit fail }'then  echo "PASS: every installed extension is on the allowlist at its pinned version."else  echo "FAIL: review the lines above before you open the editor again." >&2  exit 1fi

PowerShell, for Windows (works in Windows PowerShell 5.1 and PowerShell 7):

Both use the editor’s own --list-extensions --show-versions, so they work with cursor, code, codium or windsurf. Put the allowlist in a repo, change it through pull requests, and run the script at login or from your MDM.

This pattern is Wasowski’s, not mine: validate every plugin your team ships, build an inventory, and fail the build if the inventory differs from the approved one. I added one thing to his version. Each entry carries a hash of the plugin’s file tree, because the October build kept a legitimate name and a plausible version. A name-and-version inventory would have passed it. A content hash would not.

The --strict flag on claude plugin validate comes from his article. I could not confirm it in the docs, so check claude plugin validate --help on your pinned version before you rely on it.

name: plugin-inventory-gateon:  pull_request:    paths: ["plugins/**", "approved-inventory.txt", ".claude-code-version"]jobs:  inventory:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-node@v4        with:          node-version: "22"      - name: Install the pinned Claude Code CLI        run: npm install -g "@anthropic-ai/claude-code@$(cat .claude-code-version)"      - name: Validate plugins and diff against the approved inventory        shell: bash        run: |          set -euo pipefail          : > current-inventory.txt          for dir in plugins/*/; do            name="$(basename "$dir")"            if ! out="$(claude plugin validate --strict "$dir" 2>&1)"; then              echo "$out"              echo "::error::validation failed for $name"              exit 1            fi            version="$(jq -r '.version // "UNVERSIONED"' "$dir/.claude-plugin/plugin.json")"            tree_hash="$(cd "$dir" && find . -type f -print0 | LC_ALL=C sort -z \              | xargs -0 sha256sum | sha256sum | cut -c1-16)"            printf '%s@%s %s\n' "$name" "$version" "$tree_hash" >> current-inventory.txt          done          LC_ALL=C sort -o current-inventory.txt current-inventory.txt          if ! diff -u approved-inventory.txt current-inventory.txt; then            echo "::error::plugin inventory changed; update approved-inventory.txt in a reviewed PR"            exit 1          fi

.claude-code-version is a one-line file holding the CLI version your team has approved. Approving a plugin change then means approving a diff in approved-inventory.txt, which a reviewer can read.

This prints what a human needs to look at: authorized_keys, LaunchAgents or crontab, scheduled tasks, sshd state, listeners on 22 and outbound connections to 22. It deletes nothing. When you are not sure whether a machine is compromised, the worst thing a script can do is "fix" it before someone has captured the evidence.

#!/usr/bin/env bash# incident-review.sh: print persistence and SSH state for a human to read.# Read-only. It deletes nothing, unloads nothing, edits nothing.# Take IOCs (key comments, file names, IPs) from the primary advisories, not from this script.set -uMARKER="${1:-svc-telemetry}"   # key comment reported in Cursor's notice; pass another if advisories changesection() { printf '\n===== %s =====\n' "$1"; }section "authorized_keys (line numbers kept so you can cite them in the ticket)"for f in "$HOME/.ssh/authorized_keys" "$HOME/.ssh/authorized_keys2"; do  [ -f "$f" ] || continue  echo "--- $f (modified: $(ls -l "$f" | awk '{print $6, $7, $8}'))"  nl -ba "$f" | awk '{ printf "%s  %s  ...%s\n", $1, $2, $NF }'   # line, key type, comment  if grep -q -- "$MARKER" "$f"; then    echo "!!! '$MARKER' found on line(s): $(grep -n -- "$MARKER" "$f" | cut -d: -f1 | tr '\n' ' ')"  fidonecase "$(uname -s)" in  Darwin)    section "LaunchAgents and LaunchDaemons (newest first)"    for d in "$HOME/Library/LaunchAgents" /Library/LaunchAgents /Library/LaunchDaemons; do      [ -d "$d" ] && { echo "--- $d"; ls -lt "$d"; }    done    section "Loaded launchd jobs not from Apple's own bundle path"    launchctl list | awk 'NR==1 || $3 !~ /^com\.apple\./'    echo "(note: a fake job can use a com.apple.* label; read the plist list above too)"    section "Remote Login (sshd) state"    sudo -n systemsetup -getremotelogin 2>/dev/null || echo "run 'sudo systemsetup -getremotelogin' yourself to see Remote Login state"    ;;  Linux)    section "User crontab"    crontab -l 2>/dev/null || echo "(no crontab for $USER)"    section "systemd user units"    systemctl --user list-unit-files --state=enabled 2>/dev/null || true    section "sshd service state"    systemctl is-active ssh sshd 2>/dev/null || true    ;;esacsection "Listening on TCP 22"if command -v ss >/dev/null 2>&1; then ss -ltnp 'sport = :22' 2>/dev/nullelse lsof -nP -iTCP:22 -sTCP:LISTEN 2>/dev/null || echo "(nothing visible without sudo)"; fisection "Outbound connections to remote port 22 (a reverse tunnel looks like this)"if command -v ss >/dev/null 2>&1; then ss -tnp state established '( dport = :22 )' 2>/dev/nullelse lsof -nP -iTCP -sTCP:ESTABLISHED 2>/dev/null | awk 'NR==1 || $9 ~ /:22$/'; fisection "Hidden files in ~/.config (persistence scripts in published reports lived here)"ls -la "$HOME/.config" 2>/dev/null | awk 'NR==1 || $NF ~ /^\./'printf '\nDone. Nothing was changed. Hand this output to whoever owns the machine before you touch anything.\n'

PowerShell, for Windows (run elevated to see SYSTEM tasks and the admin keys file):

If either script shows the marker or anything you cannot explain, follow Cursor’s cleanup steps from the notice itself and rotate credentials from a different machine.

These are decisions, not measurements. I made them by reading the incident, not by testing anything.

Auto-update off for agent extensions. In Cursor and VS Code that is "extensions.autoUpdate": false in user settings, delivered through policy where I can. An agent extension can read my repos and run commands; I want a person, usually me, to see the version change before it loads. Coverage of the incident also mentions a Cursor install cooldown setting, extensions.installCooldownHours. I have not confirmed it in Cursor's docs yet, but if it behaves as described I would turn it on, because a delay of a day or two would have let the October build be found and pulled before it reached me.

One marketplace per tool. For editor extensions I pick one store per editor and do not mix sources. For Claude Code plugins, strictKnownMarketplaces lists the official marketplace and one internal one, and disableSideloadFlags stops --plugin-dir from routing around that.

Pin and review. Every extension and plugin is pinned to an exact version, plugins carry a content hash, and a version bump is a pull request. That is slower. It is meant to be.

Admin-managed connectors with small scopes. I will use Enterprise-Managed Authorization, but each connector goes to the smallest IdP group that needs it, with read-only scopes unless someone argues for more in writing.

I do not know how the build got in. Neither company has published the access path. Everything above assumes “anything with upload rights can be compromised”, which is safe but not specific.

Pinning moves the risk to me. If I pin 2.1.314 and a real security fix ships in 2.1.315, I am now the slow link. My review queue is a new single point of failure, and I am not sure my team will keep it short.

Hashes catch tampering after the fact, not before. The CI gate tells me the plugin changed. It does not tell me whether the approved version was safe in the first place. Someone still has to read the hooks.

The allowlist only sees what the CLI reports. A payload that hides outside the extensions folder, which is exactly what this one did, will not show up in an extension inventory. That is why the incident script exists, and it is still a human reading output.

I may be overweighting one incident. One bad build in one marketplace is a small sample. I think the gap between “who uploaded” and “what it can reach” is structural, but I would like more than one week of evidence before I call it settled.

Sources: Cursor security notice · Sophos · Phorion · Beaumont, DoublePulsar · BleepingComputer · Socket · Claude Code mods admin docs · Wasowski on mods · Postman per-agent install · Terraform MCP server reference · Enterprise-Managed Authorization

Tags: supply-chain-security, claude-code, cursor, vscode-extensions, ai-agents, mcp, devsecops, incident-response, developer-tools

“Verified Publisher” Is Not a Trust Boundary: What the Trojanized Claude Code Extension Teaches… was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #ai-tools 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/verified-publisher-i…] indexed:0 read:21min 2026-11-11 · —