“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. 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 https://cursor.com/blog/claude-code-extension-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 https://www.sophos.com/en-us/blog/malicious-claude-code-extension-distributed-through-cursor-marketplace 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 loader 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 https://phorion.io/blog/offical-claude-code-vsx-compromise/ 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 https://doublepulsar.com/anthropic-hacked-to-deliver-remote-access-trojan-to-claude-developer-laptops-worldwide-1148a92baae2 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 https://code.claude.com/docs/en/plugins/mods/admin . 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 https://levelup.gitconnected.com/claude-code-mods-deny-rules-claude-plugin-validate-and-a-mod-review-gate-for-teams-af65e85a389a 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 https://learning.postman.com/docs/postman-plugin/install/per-agent 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” https://medium.com/jin-system-architect/the-reach-ladder-what-you-actually-hand-an-ai-coding-agent-when-you-install-a-plugin-aa5e53291cdb 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 https://developer.hashicorp.com/terraform/docs/tools/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 https://claude.com/blog/enterprise-managed-auth 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: publisher.name@exact-version, one per line. No ranges, no wildcards.anthropic.claude-code@2.1.314ms-python.python@2026.10.1 Bash, for macOS and Linux: bash /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 : check-extensions.ps1: compare installed editor extensions with a pinned allowlist. Usage: .\check-extensions.ps1 -EditorCli cursor -Allowlist extensions.allowlist Exit codes: 0 = all pinned and matching, 1 = drift found, 2 = setup problem.param string $EditorCli = 'cursor', string $Allowlist = 'extensions.allowlist' if -not Get-Command $EditorCli -ErrorAction SilentlyContinue { Console ::Error.WriteLine "CLI '$EditorCli' not found on PATH" ; exit 2}if -not Test-Path -LiteralPath $Allowlist { Console ::Error.WriteLine "Allowlist '$Allowlist' not found" ; exit 2}$fail = $false$pin = @{} PowerShell hashtables are case-insensitive, like extension IDsforeach $raw in Get-Content -LiteralPath $Allowlist { $line = $raw -replace ' . $', '' -replace '\s', '' if -not $line { continue } $at = $line.IndexOf '@' if $at -lt 1 -or $at -eq $line.Length - 1 { "UNPINNED $line"; $fail = $true; continue } $pin $line.Substring 0, $at = $line.Substring $at + 1 }foreach $entry in & $EditorCli --list-extensions --show-versions { $entry = "$entry".Trim if -not $entry { continue } $at = $entry.IndexOf '@' if $at -lt 1 { "NOVERSION $entry"; $fail = $true; continue } $id = $entry.Substring 0, $at $ver = $entry.Substring $at + 1 if -not $pin.ContainsKey $id { "UNKNOWN $entry"; $fail = $true } elseif $pin $id -ne $ver { "MISMATCH $id installed=$ver pinned=$ $pin $id "; $fail = $true } else { "OK $entry" }}if $fail { Console ::Error.WriteLine 'FAIL: review the lines above before you open the editor again.' ; exit 1}'PASS: every installed extension is on the allowlist at its pinned version.'exit 0 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. bash /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 : incident-review.ps1: print persistence and SSH state for a human to read. Read-only. It deletes nothing, disables nothing, edits nothing. Take IOCs key comments, task names, IPs from the primary advisories, not from this script.param string $Marker = 'svc-telemetry' key comment reported in Cursor's noticefunction Section string $Title { " n===== $Title =====" }Section 'authorized keys files'$keyFiles = @ Join-Path $env:USERPROFILE '.ssh\authorized keys' , Join-Path $env:ProgramData 'ssh\administrators authorized keys' foreach $f in $keyFiles { if -not Test-Path -LiteralPath $f { continue } $item = Get-Item -LiteralPath $f "--- $f modified: $ $item.LastWriteTime " $n = 0 foreach $l in Get-Content -LiteralPath $f { $n++ $parts = $l -split '\s+' '{0} {1} ...{2}' -f $n, $parts 0 , $parts -1 line, key type, comment } $hits = Select-String -LiteralPath $f -SimpleMatch $Marker if $hits { " '$Marker' found on line s : $ $hits.LineNumber -join ' ' " }}Section 'Scheduled tasks outside \Microsoft\ newest registration first 'Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $ .TaskPath -notlike '\Microsoft\ ' } | Sort-Object { $ .Date } -Descending | Select-Object TaskName, TaskPath, State, @{ n = 'RunAs'; e = { $ .Principal.UserId } }, Date | Format-Table -AutoSize | Out-String -Width 200Section 'Startup folders'@ Environment ::GetFolderPath 'Startup' , Environment ::GetFolderPath 'CommonStartup' | ForEach-Object { "--- $ " Get-ChildItem -LiteralPath $ -Force -ErrorAction SilentlyContinue | Select-Object Name, LastWriteTime | Format-Table -AutoSize | Out-String}Section 'OpenSSH server service'$svc = Get-Service -Name sshd -ErrorAction SilentlyContinueif $svc { $svc | Select-Object Name, Status, StartType | Format-Table -AutoSize | Out-String }else { 'sshd service not installed' }Section 'Listening on TCP 22'Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Format-Table -AutoSize | Out-StringSection 'Outbound connections to remote port 22 a reverse tunnel looks like this 'Get-NetTCPConnection -RemotePort 22 -State Established -ErrorAction SilentlyContinue | Select-Object LocalAddress, RemoteAddress, RemotePort, OwningProcess, @{ n = 'Process'; e = { Get-Process -Id $ .OwningProcess -ErrorAction SilentlyContinue .Path } } | Format-Table -AutoSize | Out-String -Width 200Section 'Hidden or recent items in %USERPROFILE%\.config published reports used this folder 'Get-ChildItem -LiteralPath Join-Path $env:USERPROFILE '.config' -Force -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 20 Name, LastWriteTime | Format-Table -AutoSize | Out-String" nDone. Nothing was changed. Hand this output to whoever owns the machine before you touch anything." 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 https://cursor.com/blog/claude-code-extension-security-notice · Sophos https://www.sophos.com/en-us/blog/malicious-claude-code-extension-distributed-through-cursor-marketplace · Phorion https://phorion.io/blog/offical-claude-code-vsx-compromise/ · Beaumont, DoublePulsar https://doublepulsar.com/anthropic-hacked-to-deliver-remote-access-trojan-to-claude-developer-laptops-worldwide-1148a92baae2 · BleepingComputer https://www.bleepingcomputer.com/news/security/malicious-claude-code-extension-on-cursor-planted-ssh-backdoors/ · Socket https://socket.dev/blog/claude-code-extension-compromise · Claude Code mods admin docs https://code.claude.com/docs/en/plugins/mods/admin · Wasowski on mods https://levelup.gitconnected.com/claude-code-mods-deny-rules-claude-plugin-validate-and-a-mod-review-gate-for-teams-af65e85a389a · Postman per-agent install https://learning.postman.com/docs/postman-plugin/install/per-agent · Terraform MCP server reference https://developer.hashicorp.com/terraform/docs/tools/mcp-server/reference · Enterprise-Managed Authorization https://claude.com/blog/enterprise-managed-auth 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… https://pub.towardsai.net/verified-publisher-is-not-a-trust-boundary-what-the-trojanized-claude-code-extension-teaches-d8c5b2395dd0 was originally published in Towards AI https://pub.towardsai.net on Medium, where people are continuing the conversation by highlighting and responding to this story.