Cursor Extensions Audit: 12 Steps, 60 Min [2026] A tampered build of Anthropic's Claude Code extension (Anthropic.claude-code versions 2.1.312 and 2.1.313) sat in Cursor's extension marketplace for roughly eleven hours on October 29, 2026, attempting to install a persistent SSH backdoor on every Mac and Windows machine that loaded it, according to Cursor's security notice. Cursor said the versions, live from about 2:27am to 1:35pm Pacific and not built by Anthropic, collected host and user details, tried to add an attacker-controlled SSH key labeled svc-telemetry to authorized_keys, and dropped LaunchAgent and scheduled-task persistence with retries roughly every 60 seconds; the company removed the versions, shut and rebuilt the marketplace, rotated keys, and said its audit found no other extensions tampered with. The incident prompted a published 12-step, 60-minute Python audit kit that inventories installed extensions, flags known-bad versions, checks for backdoor traces, and fails CI jobs that recommend unapproved extensions. On October 29, 2026, a tampered build of the Claude Code extension sat in Cursor’s extension marketplace for about eleven hours, and it tried to leave a persistent SSH backdoor on every Mac and Windows machine that loaded it. Security write-ups of the incident https://www.orchestriai.com/blog/cursor-marketplace-claude-code-extension-malware-check agree with Cursor’s own notice on the outline: versions 2.1.312 and 2.1.313 of Anthropic.claude-code, live between roughly 2:27am and 1:35pm Pacific, and not built by Anthropic. That episode turned a boring housekeeping chore into an urgent one. Cursor extensions run with your user’s permissions, so each one can read your repos, your environment variable names, and your SSH folder. Most developers have never written down which ones they have installed, which is exactly the gap behind the tampered Claude Code extension story https://shattered.io/cursor-tampered-claude-code-extension-5000-users-2026/ . The fix is a small Python kit with no dependencies. It inventories every extension in your Cursor folder, flags known-bad versions and indicators of compromise, checks the machine for the backdoor traces Cursor described, pins the versions you trust, and fails a CI job when a repo recommends something unapproved. It is small enough to build and test in a single sitting. Don't miss new tech stories on Google Add Shattered once in the Google app and our stories show up in your news suggestions. Add now https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F What Happened on October 29 and Why Cursor Extensions Need an Audit Cursor published a security notice the same day. It said the malicious versions collected host and user details, tried to add an attacker-controlled SSH key to authorized keys , tried to enable the local SSH service, and tried to open a reverse tunnel. Cursor also said that turning on the SSH service needs administrator rights, which the extension lacks on most macOS and Windows machines, so on those hosts the login step fails. The key, the persistence and the host report still happened, which is why the notice told everyone to remediate regardless. | Item | What Cursor’s notice says | |---|---| | Affected package | Anthropic.claude-code 2.1.312 and 2.1.313, macOS and Windows | | Exposure window | About 2:27am to 1:35pm PT on October 29, 2026 | | Who built it | Not Anthropic, according to Cursor | | Key label | An SSH public key labelled svc-telemetry | | Persistence | LaunchAgent on macOS, scheduled task with a Startup-script fallback on Windows, retry roughly every 60 seconds | | Cursor’s response | Removed the versions, blocked distribution, shut the marketplace, audited access, rotated keys, rebuilt it and brought it back online | | Attacker server | Taken offline with the hosting provider, per Cursor | Cursor also said its audit confirmed no other extensions were tampered with. Treat that as the vendor’s statement rather than an independent finding. The notice adds a warning that matters for everything below: uninstalling the extension may not be enough, because the payload drops standalone persistence scripts that keep running. One report says the compromised version was replaced by a patched 2.1.314, and that how the attackers got in remained unexplained https://rocketnews.com/2026/11/anthropic-vs-code-extension-on-cursor-backdoored-for-nearly-12-hours-2/ . Cursor’s notice does not confirm that, so read it as a reported detail. The audience for this problem is large. In the 2025 Stack Overflow developer survey, per the survey press release https://stackoverflow.co/company/press/archive/stack-overflow-2025-developer-survey/ , “Cursor was used by 18% of respondents in the survey.” The same release says “Three quarters of respondents, or 75.3%, said they do not trust AI answers.” Developers distrust the output yet hand the tool, and every extension beside it, wide access to their machines. The survey also found Visual Studio Code used by 75.9% of respondents, and Cursor inherits that extension model. Why a publisher allowlist alone would not have caught it The bad builds shipped under the real extension ID, Anthropic.claude-code. A policy that trusts the publisher anthropic would have waved them through. Only checks tied to the version, the file contents and the host state catch this class of attack, so the kit you build below layers a deny list, content scanning and a host check on top of the allowlist. Extensions are also not the only code surface in Cursor, so it is worth pairing this work with a pass to audit your Cursor MCP servers https://shattered.io/audit-cursor-mcp-servers-allowlist-hooks-12-steps-2026/ . Prerequisites and Versions The kit uses only the Python standard library, so there is nothing to install beyond the tools below. - Python 3.10 or newer. The code in this walkthrough was run on Python 3.12.3. - The latest Cursor build, with the cursor command on your PATH. If your build lacks it, install the shell command from the Command Palette. - macOS, Windows or Linux. The October 29 incident affected macOS and Windows, but the file scans run everywhere. - PowerShell 5.1 or newer on Windows, for the host checks. - Git, so the policy and lock file live in version control. - Optional: a GitHub repository and Actions, for the pull request gate in Step 12. Confirm the basics before you start: python3 --version cursor --version cursor --list-extensions --show-versions The last command prints one publisher.name@version line per extension. Save that output, since it is the baseline you compare the kit against in Step 3. Note that Cursor extensions live in ~/.cursor/extensions on a default install. Each folder is named after the extension and its version, and each holds a package.json . If your install keeps them elsewhere, set the CURSOR EXT DIR environment variable and the kit follows it. How Cursor Extensions Are Sourced and Vetted Cursor’s help page on extensions explains where third-party extensions come from. Cursor uses the Open VSX registry rather than the Microsoft marketplace, and it routes search and downloads through its own marketplace proxy, which runs automated malware and supply-chain analysis before an extension is offered. Many popular VS Code extensions are available, and some Microsoft-only ones are replaced by audited builds from Anysphere. The same page carries a warning that applies directly to this tutorial. The same publisher.extension name can point to different publishers or different code on Open VSX than on the Microsoft marketplace, so treat extension IDs like dependencies and install from publishers you trust. An ID is a claim, and the files behind it are the evidence. | Control layer | Who runs it | What it helps stop | Gap | |---|---|---|---| | Marketplace analysis | Cursor | Known-bad uploads before they appear in search | Cannot catch everything, as October 29 showed | | Verified publisher badge | Cursor, on request from publishers | Impersonation of a publisher name | Confirms identity, not code safety | | Install cooldown | Team admin or user setting | Short-lived malicious uploads | Does nothing for versions you already hold | | Allowed extensions | Team admin or MDM | Installs from unapproved publishers or IDs | A trusted ID can still ship a bad version | | Signature verification | Enterprise teams | Packages without a valid Open VSX signature | Client-side install control only | | Local audit and lock this kit | You | Known-bad versions, indicators, drift, host traces | Cannot see obfuscated payloads | Cursor’s own page says no single control replaces the others, and the table backs that up. The kit fills the last row, the one only you can run on your own disk. It also keeps VS Code extensions separate from newer plugin surfaces. Reports describe Cursor plugins as a different system that can bundle rules, hooks and MCP servers, so audit those in their own pass, starting with how you set up MCP servers in Cursor https://shattered.io/set-up-cursor-mcp-servers-2026/ . Steps 1 to 3: Build the Project, Write the Policy, Inventory Installs Step 1: Create the project. Make a folder, initialize Git, and lay out the package. The empty init .py files make the folders importable. mkdir cursor-ext-audit && cd cursor-ext-audit git init mkdir ext audit tests touch ext audit/ init .py tests/ init .py Step 2: Write the policy file. Policy lives in one JSON file so a reviewer can read it in a pull request. The allowlist holds publishers you trust. The deny list holds exact bad versions, and the indicator list holds strings that should never appear in extension code. The two indicators below come from Cursor’s notice: the attacker IP found in the modified extension.js , and the key label. { "allow publishers": "anysphere", "anthropic", "esbenp", "dbaeumer", "eamodio", "redhat" , "allow ids": , "deny": { "id": "anthropic.claude-code", "versions": "2.1.312", "2.1.313" , "reason": "Malicious builds named in Cursor's October 29, 2026 security notice" } , "ioc strings": "134.209.82.225", "svc-telemetry" , "risky activation": " ", "onStartupFinished" , "agent surfaces": "mcpServerDefinitionProviders", "languageModelTools", "chatParticipants" } Two choices deserve a second look. The allow publishers list is an example, so replace it with the publishers your team actually uses. And versions in a deny rule can be left empty to ban an extension ID entirely, which is the right move for something you never want installed. Step 3: Inventory every installed extension. The inventory module walks the extensions folder, reads each package.json , and builds a normalized ID in the form publisher.name. It skips folders listed in the .obsolete file, since those are leftovers an update marked for removal. It also computes a tree hash, which is a SHA-256 https://shattered.io/category/cryptography/ over every file path and file hash in an extension folder, so Step 7 can detect any byte-level change. python import hashlib import json import os from pathlib import Path def default dir - Path: env = os.environ.get "CURSOR EXT DIR" return Path env if env else Path.home / ".cursor" / "extensions" def obsolete root: Path - set: try: return set json.loads root / ".obsolete" .read text except OSError, ValueError : return set def scan root: Path - list: skip = obsolete root found = for folder in sorted root.iterdir : manifest = folder / "package.json" if not folder.is dir or folder.name.startswith "." or folder.name in skip: continue if not manifest.is file : continue try: data = json.loads manifest.read text encoding="utf-8" except OSError, ValueError : found.append {"id": folder.name, "version": "?", "path": folder, "manifest": {}, "broken": True} continue publisher = str data.get "publisher", "unknown" .lower name = str data.get "name", "unknown" .lower found.append {"id": f"{publisher}.{name}", "version": str data.get "version", "?" , "path": folder, "manifest": data, "broken": False} return found def file sha path: Path - bytes: digest = hashlib.sha256 with path.open "rb" as fh: for chunk in iter lambda: fh.read 1 << 20 , b"" : digest.update chunk return digest.digest def tree hash folder: Path - str: digest = hashlib.sha256 for item in sorted p for p in folder.rglob " " if p.is file : rel = item.relative to folder .as posix if rel.startswith "." : continue digest.update rel.encode digest.update file sha item return digest.hexdigest The tree hash skips top-level dotfiles so Cursor’s own bookkeeping files do not cause false drift. Run the inventory against your real folder and compare the count with the baseline you saved in the prerequisites. A mismatch usually means a folder with a broken manifest, which the kit reports instead of hiding. Steps 4 to 6: Score Risk, Scan for Indicators, Check the Host Step 4: Write the rules. Each extension runs through a handful of checks that produce findings with a severity. Some checks come straight from the October 29 behavior, such as an extension that touches authorized keys . Others flag surfaces that deserve a human look, such as an extension that starts at launch or ships a native binary. python from dataclasses import dataclass from pathlib import Path SEVERITY = {"low": 1, "medium": 2, "high": 3, "critical": 4} CODE SUFFIXES = {".js", ".cjs", ".mjs", ".json", ".sh", ".ps1", ".bat", ".py", ".plist"} NATIVE SUFFIXES = {".node", ".exe", ".dll", ".dylib"} PERSIST MARKERS = "authorized keys", "LaunchAgents", "schtasks", "Register-ScheduledTask" MAX BYTES = 5 000 000 @dataclass class Finding: severity: str ext id: str version: str code: str message: str def walk folder: Path : for item in folder.rglob " " : if item.is file : yield item def read item: Path - str: try: if item.stat .st size MAX BYTES: return "" return item.read text encoding="utf-8", errors="ignore" except OSError: return "" def evaluate ext: dict, policy: dict - list: ext id, version = ext "id" , ext "version" manifest = ext "manifest" out = def add sev, code, msg : out.append Finding sev, ext id, version, code, msg if ext.get "broken" : add "medium", "BROKEN", "package.json is unreadable" return out for rule in policy.get "deny", : if rule "id" == ext id and not rule "versions" or version in rule "versions" : add "critical", "DENY", rule "reason" publisher = ext id.split ".", 1 0 if publisher not in policy "allow publishers" and ext id not in policy "allow ids" : add "medium", "PUBLISHER", f"publisher '{publisher}' is not on the allowlist" events = set manifest.get "activationEvents", hits = events & set policy "risky activation" if hits: add "low", "ACTIVATION", f"starts early via {', '.join sorted hits }" contributes = manifest.get "contributes", {} surfaces = s for s in policy "agent surfaces" if s in contributes if surfaces: add "medium", "AGENT SURFACE", f"contributes {', '.join surfaces }" iocs = policy "ioc strings" for item in walk ext "path" : rel = item.relative to ext "path" .as posix if item.suffix in NATIVE SUFFIXES: add "medium", "NATIVE", f"ships native binary {rel}" if item.suffix not in CODE SUFFIXES: continue text = read item for ioc in iocs: if ioc in text: add "critical", "IOC", f"{rel} contains indicator '{ioc}'" for marker in PERSIST MARKERS: if marker in text: add "high", "PERSIST", f"{rel} references '{marker}'" return out | Finding code | Severity | Trigger | |---|---|---| | DENY | critical | ID and version match a deny rule | | IOC | critical | A file contains a string from the indicator list | | PERSIST | high | Code references authorized keys, LaunchAgents, schtasks or Register-ScheduledTask | | PUBLISHER | medium | Publisher and ID are both missing from the allowlist | | AGENT SURFACE | medium | Manifest contributes MCP server providers, language model tools or chat participants | | NATIVE | medium | Ships a .node, .exe, .dll or .dylib file | | BROKEN | medium | package.json cannot be parsed | | ACTIVATION | low | Activates on startup through the or onStartupFinished events | The agent-surface check exists because VS Code’s own documentation describes extensions that contribute agent tools and MCP servers. An extension that adds tools your agent can call has more reach than a color theme, and it should show up in review. Expect legitimate hits in the PERSIST and NATIVE rows too, for example a remote-development extension that really does handle SSH. The kit surfaces the file path so you can judge it. Step 5: Run the rules against a planted fixture. Never test a security scanner on real malware. Build a fake extensions folder that contains one clean extension, one that starts early, one that contributes an agent tool, and one that imitates the October 29 build with the two indicator strings. python python3 - <<'EOF' import json from pathlib import Path root = Path "/tmp/fixture-ext" root.mkdir exist ok=True def mk pub, name, ver, js, extra=None : d = root / f"{pub}.{name}-{ver}" d.mkdir exist ok=True m = {"publisher": pub, "name": name, "version": ver, "main": "./extension.js"} m.update extra or {} d / "package.json" .write text json.dumps m d / "extension.js" .write text js mk "esbenp", "prettier-vscode", "1.0.0", "module.exports = {}" mk "redhat", "vscode-yaml", "1.0.0", "module.exports = {}", {"activationEvents": "onStartupFinished" } mk "acme", "agent-tools", "0.4.1", "module.exports = {}", {"contributes": {"languageModelTools": }} mk "anthropic", "claude-code", "2.1.312", "const k = 'svc-telemetry'; const h = '134.209.82.225';" EOF CURSOR EXT DIR=/tmp/fixture-ext python3 -m ext audit.cli audit Run it after Step 7 adds the CLI module. The output looks like this: scanned 4 extensions in /tmp/fixture-ext CRITICAL DENY email protected Malicious builds named in Cursor's October 29, 2026 security notice CRITICAL IOC email protected extension.js contains indicator '134.209.82.225' CRITICAL IOC email protected extension.js contains indicator 'svc-telemetry' MEDIUM PUBLISHER email protected publisher 'acme' is not on the allowlist MEDIUM AGENT SURFACE email protected contributes languageModelTools LOW ACTIVATION email protected starts early via onStartupFinished The command exits with code 1, because the default --fail-on level is high and three findings are critical. Notice that the fake Anthropic build passes the publisher check, since anthropic sits on the allowlist, yet it still draws three critical findings. That is the layered design doing its job. Step 6: Check the host. File scans tell you what is installed. They cannot tell you whether the payload already ran. The host check looks for the traces Cursor described: the labelled key in the SSH authorized keys files, persistence entries that contain an indicator, and running processes that mention one. On macOS it reads the user’s LaunchAgents folder. On Windows it reads the Startup folder and the administrators key file under ProgramData. python import os import platform import subprocess from pathlib import Path from .rules import Finding def read path: Path - str: try: return path.read text encoding="utf-8", errors="ignore" except OSError: return "" def hit text: str, iocs: list : return next i for i in iocs if i in text , None def line with text: str, ioc: str - str: line = next ln for ln in text.splitlines if ioc in ln return line.strip :100 def key files - list: files = Path.home / ".ssh" / "authorized keys" if platform.system == "Windows": base = Path os.environ.get "ProgramData", r"C:\ProgramData" files.append base / "ssh" / "administrators authorized keys" return files def persistence dirs - list: system = platform.system if system == "Darwin": return Path.home / "Library" / "LaunchAgents" if system == "Windows": appdata = Path os.environ.get "APPDATA", "" return appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" return def process listing - str: if platform.system == "Windows": cmd = "powershell", "-NoProfile", "-Command", "Get-CimInstance Win32 Process | Select-Object -ExpandProperty CommandLine" else: cmd = "ps", "-axo", "command=" try: return subprocess.run cmd, capture output=True, text=True, timeout=30 .stdout except OSError, subprocess.SubprocessError : return "" def run iocs: list - list: out = for f in key files : hit = hit read f , iocs if hit: out.append Finding "critical", "host", "-", "HOST KEY", f"{f} contains '{hit}'" for folder in persistence dirs : if not folder.is dir : continue for item in folder.iterdir : hit = hit read item , iocs if item.is file else None if hit: out.append Finding "critical", "host", "-", "HOST PERSIST", f"{item} contains '{hit}'" procs = process listing hit = hit procs, iocs if hit: out.append Finding "critical", "host", "-", "HOST PROC", f"process mentions '{hit}': { line with procs, hit }" return out Cursor’s notice also gives a one-line check you can run without any of this code. On macOS: grep -qs svc-telemetry ~/.ssh/authorized keys && echo "svc-telemetry key found" || echo "No key found; machine not impacted" On Windows, in PowerShell: php $files = "$HOME\.ssh\authorized keys", "C:\ProgramData\ssh\administrators authorized keys" | Where-Object { Test-Path $ } if $files -and Select-String svc-telemetry $files { "svc-telemetry key found" } else { "No key found; machine not impacted" } If either command finds the key, treat the machine as impacted. Cursor’s notice provides remediation scripts for both platforms, remediate-macos.sh and remediate-windows.ps1 , and says to rotate credentials and sessions reachable from the host from a trusted system. Use those scripts instead of improvising. Hosts that run agents and editors side by side benefit from the same isolation thinking that applies to isolating AI agents https://shattered.io/suse-ai-factory-nvidia-openshell-agent-security-2026/ from the rest of a workstation. What the host check cannot prove A clean result means the traces from this one incident are absent. It does not prove the machine is clean. Cursor said the attacker’s server no longer works, so host details are not reaching anyone and the reverse shell is dead. It also said the SSH step fails on hosts where Cursor did not run with administrator privileges. Neither point makes a past exposure harmless, because the key and the persistence entries were still written. If the check finds anything, isolate the host and rotate credentials. If it finds nothing but you installed one of the bad versions inside the window, run Cursor’s remediation script anyway. Steps 7 and 8: Lock Versions and Install From the Pinned List Step 7: Add the command line and write the lock file. The CLI module ties the pieces together. The audit command runs the rules and the optional host check. The lock command records every installed extension with its version and tree hash. The verify command compares the live folder against that record, and install replays it. The recs command, used in Step 12, checks a repo’s recommendation file against policy. python import argparse import json import re import subprocess import sys from dataclasses import asdict from pathlib import Path from . import hostcheck, inventory, rules def load policy path: Path - dict: return json.loads path.read text encoding="utf-8" def load jsonc path: Path - dict: text = re.sub r"^\s //. $", "", path.read text encoding="utf-8" , flags=re.M return json.loads re.sub r", \s }\ ", r"\1", text def cmd audit args, policy, root - int: findings = exts = inventory.scan root for ext in exts: findings += rules.evaluate ext, policy if args.host: findings += hostcheck.run policy "ioc strings" findings.sort key=lambda f: -rules.SEVERITY f.severity if args.json: print json.dumps asdict f for f in findings , indent=2 else: print f"scanned {len exts } extensions in {root}" for f in findings: print f"{f.severity.upper :8} {f.code:14} {f.ext id}@{f.version} {f.message}" if not findings: print "no findings" worst = max rules.SEVERITY f.severity for f in findings , default=0 return 1 if worst = rules.SEVERITY args.fail on else 0 def cmd lock args, policy, root - int: entries = {"id": e "id" , "version": e "version" , "sha256": inventory.tree hash e "path" } for e in inventory.scan root if not e "broken" entries.sort key=lambda e: e "id" args.out.write text json.dumps {"extensions": entries}, indent=2 + "\n" print f"wrote {len entries } entries to {args.out}" return 0 def cmd verify args, policy, root - int: locked = {e "id" : e for e in json.loads args.lock.read text "extensions" } live = {e "id" : e for e in inventory.scan root if not e "broken" } drift = 0 for ext id, ext in sorted live.items : want = locked.get ext id if want is None: print f"NEW {ext id}@{ext 'version' } is not in the lock file" drift += 1 elif want "version" = ext "version" : print f"VERSION {ext id} locked {want 'version' }, installed {ext 'version' }" drift += 1 elif want "sha256" = inventory.tree hash ext "path" : print f"HASH {ext id}@{ext 'version' } files differ from the lock" drift += 1 for ext id in sorted set locked - set live : print f"MISSING {ext id} is locked but not installed" drift += 1 print "lock matches" if not drift else f"{drift} drift item s " return 1 if drift else 0 def cmd install args, policy, root - int: for entry in json.loads args.lock.read text "extensions" : blocked = any r "id" == entry "id" and not r "versions" or entry "version" in r "versions" for r in policy "deny" if blocked: print f"REFUSED {entry 'id' }@{entry 'version' } is on the deny list" return 2 for entry in json.loads args.lock.read text "extensions" : cmd = args.cli, "--install-extension", f"{entry 'id' }@{entry 'version' }" print " ".join cmd if not args.dry run: subprocess.run cmd, check=True return 0 def cmd recs args, policy, root - int: recs = load jsonc args.file .get "recommendations", bad = r for r in recs if r.split ".", 1 0 .lower not in policy "allow publishers" and r.lower not in policy "allow ids" for r in bad: print f"UNAPPROVED {r}" print f"{len recs } recommendations, {len bad } unapproved" return 1 if bad else 0 def main argv=None - int: p = argparse.ArgumentParser prog="ext-audit" p.add argument "--dir", type=Path p.add argument "--policy", type=Path, default=Path "policy.json" sub = p.add subparsers dest="cmd", required=True a = sub.add parser "audit" a.add argument "--host", action="store true" a.add argument "--json", action="store true" a.add argument "--fail-on", default="high", choices=list rules.SEVERITY a.set defaults fn=cmd audit lk = sub.add parser "lock" lk.add argument "--out", type=Path, default=Path "extensions.lock.json" lk.set defaults fn=cmd lock v = sub.add parser "verify" v.add argument "--lock", type=Path, default=Path "extensions.lock.json" v.set defaults fn=cmd verify i = sub.add parser "install" i.add argument "--lock", type=Path, default=Path "extensions.lock.json" i.add argument "--cli", default="cursor" i.add argument "--dry-run", action="store true" i.set defaults fn=cmd install r = sub.add parser "recs" r.add argument "file", type=Path, nargs="?", default=Path ".vscode/extensions.json" r.set defaults fn=cmd recs args = p.parse args argv policy = load policy args.policy root = args.dir or inventory.default dir return args.fn args, policy, root if name == " main ": sys.exit main Run audit on a machine you believe is clean, fix or approve every finding, and only then lock it. A lock file written from a dirty machine pins the dirt. python3 -m ext audit.cli audit --host python3 -m ext audit.cli lock python3 -m ext audit.cli verify On a fixture with three clean extensions, lock and verify print: wrote 3 entries to extensions.lock.json lock matches After one extension file changes and an unlisted extension appears, verify reports drift and exits with code 1: NEW email protected is not in the lock file HASH email protected files differ from the lock 2 drift item s Commit extensions.lock.json next to the policy. A lock change then shows up in code review as a diff, with the version and hash on the line, which is far easier to question than a silent auto-update. Step 8: Install from the pinned list. The install command refuses to proceed if any locked entry matches a deny rule, then calls the Cursor CLI once per entry with an exact version. Start with a dry run to see what it would do. python3 -m ext audit.cli install --dry-run cursor --install-extension email protected cursor --install-extension email protected cursor --install-extension email protected Drop --dry-run on a fresh machine to rebuild the same extension set. One caveat: Open VSX may not carry every historical version, and platform-specific builds can hash differently between macOS and Windows. Keep one lock file per operating system if your team mixes them, and expect an occasional version that needs to be bumped rather than pinned forever. Steps 9 and 10: Harden Editor Settings and Team Controls Step 9: Turn off silent updates and add a cooldown. A pinned extension stays pinned only if the editor stops updating it behind your back. Open the user settings JSON from the Command Palette and add the following. Auto-update is off, update checks stay on so you still learn about new versions, and an install cooldown holds new marketplace releases for a day. { "extensions.autoUpdate": false, "extensions.autoCheckUpdates": true, "extensions.installCooldownHours": 24 } Cursor’s help page documents extensions.installCooldownHours as a per-user setting. It defers installs and updates until a marketplace version has been published for the number of hours you set. How long should the cooldown be? The 24 above is an example, so pick the number your risk posture accepts. Remember that a cooldown does nothing for a version you already installed. On October 29 the bad builds were live for roughly eleven hours, so a cooldown longer than that would have kept a fresh install or update away from them, assuming the marketplace pulled the versions in time. Longer waits cost you fixes, because a legitimate security patch also sits in the queue. Most teams land between one day and three days and shorten it for urgent patches by hand. Step 10: Apply team-wide controls. Settings files protect one machine. Teams need controls developers cannot opt out of. According to Cursor’s help page, team owners and admins can set a Marketplace Install Cooldown under Team settings, then Security and automation, and the default is 0, meaning disabled. When the value is above 0 it applies fleet-wide and overrides the per-user setting, so developers cannot opt back into instant updates. Two more controls sit beside it. Admins can restrict installs to approved publishers or extension IDs from the team dashboard or through MDM, using a setting called AllowedExtensions . Enterprise teams can also require valid Open VSX signatures before install. Cursor says the cooldown and signature checks are client-side install controls that complement marketplace analysis and do not replace it. If you manage a team, review the rest of your admin surface at the same time, starting with locking down the Cursor dashboard https://shattered.io/secure-cursor-ai-dashboard-2026/ . Your allowlist in policy.json and the AllowedExtensions setting should say the same thing. Keep one source of truth, generate the other from it, and review both in the same pull request. Extensions also see whatever files the editor can see, so pair this step with a .cursorignore file that keeps secrets away from agents https://shattered.io/cursor-rules-linter-agents-md-cursorignore-12-steps-2026/ . Step 11: Test the Whole Kit A security tool that nobody tests drifts into false comfort. The test file below builds throwaway extension folders in a temporary directory and checks each rule, the lock hash, the obsolete-folder skip, the host key check with a planted file, and the CLI exit code. python import json import os import tempfile import unittest from pathlib import Path from ext audit import cli, hostcheck, inventory, rules POLICY = json.loads Path "policy.json" .read text def make ext root: Path, publisher, name, version, js="", extra=None : folder = root / f"{publisher}.{name}-{version}" folder.mkdir parents=True manifest = {"publisher": publisher, "name": name, "version": version, "main": "./extension.js"} manifest.update extra or {} folder / "package.json" .write text json.dumps manifest folder / "extension.js" .write text js return folder class AuditTests unittest.TestCase : def setUp self : self.tmp = tempfile.TemporaryDirectory self.root = Path self.tmp.name def tearDown self : self.tmp.cleanup def codes self : out = for ext in inventory.scan self.root : out += f.code for f in rules.evaluate ext, POLICY return out def test clean extension has no findings self : make ext self.root, "esbenp", "prettier-vscode", "1.0.0", "module.exports = {}" self.assertEqual self.codes , def test denied version is critical self : make ext self.root, "anthropic", "claude-code", "2.1.312", "x" self.assertIn "DENY", self.codes def test other version of same id is not denied self : make ext self.root, "anthropic", "claude-code", "9.9.9", "x" self.assertNotIn "DENY", self.codes def test ioc string is critical self : make ext self.root, "acme", "helper", "1.0.0", "const k = 'svc-telemetry';" self.assertIn "IOC", self.codes def test persistence marker is high self : make ext self.root, "esbenp", "prettier-vscode", "1.0.0", "fs.appendFile 'authorized keys', k " self.assertIn "PERSIST", self.codes def test unknown publisher flagged self : make ext self.root, "acme", "helper", "1.0.0", "ok" self.assertIn "PUBLISHER", self.codes def test agent surface and activation self : make ext self.root, "esbenp", "tools", "1.0.0", "ok", {"activationEvents": " " , "contributes": {"languageModelTools": }} found = self.codes self.assertIn "ACTIVATION", found self.assertIn "AGENT SURFACE", found def test obsolete folders are skipped self : folder = make ext self.root, "acme", "old", "1.0.0", "svc-telemetry" self.root / ".obsolete" .write text json.dumps {folder.name: True} self.assertEqual inventory.scan self.root , def test tree hash changes when a file changes self : folder = make ext self.root, "esbenp", "prettier-vscode", "1.0.0", "a" before = inventory.tree hash folder folder / "extension.js" .write text "b" self.assertNotEqual before, inventory.tree hash folder def test host key check finds planted key self : home = self.root / "home" home / ".ssh" .mkdir parents=True home / ".ssh" / "authorized keys" .write text "ssh-ed25519 AAAA svc-telemetry\n" old = os.environ.get "HOME" os.environ "HOME" = str home try: found = f.code for f in hostcheck.run POLICY "ioc strings" finally: os.environ "HOME" = old self.assertIn "HOST KEY", found def test cli exit code follows fail on self : make ext self.root, "acme", "helper", "1.0.0", "svc-telemetry" code = cli.main "--dir", str self.root , "--policy", "policy.json", "audit" self.assertEqual code, 1 if name == " main ": unittest.main Run the suite from the project root. The explicit -t . matters, because it makes the tests folder importable and keeps the ext audit imports working. python3 -m unittest discover -s tests -t . -v Ran 11 tests in 0.053s OK The host key test sets the HOME variable to a temporary folder, plants an authorized keys file containing the label, and expects a critical finding. The same run proves that a clean file does not trigger one. I ran this suite on Linux. The Windows branches call standard PowerShell and read standard paths, but test them on one Windows machine before you roll the kit out. Step 12: Schedule the Audit and Gate Pull Requests Step 12: Run it on a schedule and in CI. A one-time audit finds yesterday’s problem. A recurring one catches the next. On macOS and Linux, a cron entry runs the audit each weekday and appends to a log: 0 9 1-5 cd ~/cursor-ext-audit && python3 -m ext audit.cli audit --host --fail-on critical ~/ext-audit.log 2 &1 On Windows, Task Scheduler does the same job: schtasks /Create /SC DAILY /TN "CursorExtAudit" /ST 09:00 /TR "cmd /c cd /d C:\tools\cursor-ext-audit && python -m ext audit.cli audit --host --fail-on critical %USERPROFILE%\ext-audit.log 2 &1" Repositories also recommend extensions. A workspace recommendation file is how a team nudges everyone toward the same tools, and it is a quiet way for an unvetted extension to spread. Cursor builds on VS Code, so the .vscode/extensions.json convention works the same way in practice, though you should confirm that on your own build. The recs command reads that file, tolerates comments and trailing commas, and fails when a recommendation is not on the allowlist: UNAPPROVED acme.agent-tools 2 recommendations, 1 unapproved Wire it into GitHub Actions so a pull request that adds a recommendation has to pass policy first: name: ext-audit on: pull request: paths: - ".vscode/extensions.json" - "policy.json" - "ext audit/ " - "tests/ " jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Unit tests run: python -m unittest discover -s tests -t . -v - name: Check workspace recommendations run: python -m ext audit.cli recs .vscode/extensions.json The action versions shown are examples, so pin them to the versions or commit hashes your organization has approved. If you already run the editor’s agent headless, the same job can sit beside a workflow for Cursor CLI in GitHub Actions https://shattered.io/cursor-cli-github-actions-headless-agent-2026/ . The Complete Working Project The finished kit is a small repository with the files below. cursor-ext-audit/ policy.json extensions.lock.json generated by the lock command ext audit/ init .py inventory.py rules.py hostcheck.py cli.py tests/ init .py test audit.py .github/workflows/ext-audit.yml A short run order covers daily use. Start with the unit tests whenever you edit a rule. Run audit --host before you lock anything, then lock , commit the result, and rely on verify from that day forward. Use install to rebuild a fresh machine and recs in CI. - audit : weekly, and after any editor update. Exit code 1 means a finding at or above the fail-on level. - audit --host : after any security notice. Exit code 1 means a critical trace on the machine. - lock : after you approve a clean state. - verify : daily, or before a release. Exit code 1 means drift from the lock file. - install : on a new or rebuilt machine. Exit code 2 means a locked entry matched the deny list. - recs : in CI on every pull request. Exit code 1 means an unapproved recommendation. You can also call the audit from Cursor hooks https://shattered.io/cursor-hooks-tutorial-guard-agent-12-steps-2026/ so every agent session starts with a quick check. Keep the host check out of the hook, since it shells out to list processes and slows every launch. Common Pitfalls Teams that adopt this kind of tooling tend to trip over the same six things. - Trusting the publisher name. The October 29 builds used a real extension ID. Keep the deny list and content checks on even when the publisher is allowlisted. - Locking a dirty machine. Pinning versions does not clean them. Run the audit, resolve findings, then lock. - Assuming a clean scan means a safe extension. The scanner reads plain strings. Obfuscated or downloaded payloads slip past it, so treat results as a signal and keep marketplace controls on. - Comparing the wrong version number. According to the Claude Code VS Code documentation https://code.claude.com/docs/en/vs-code , the extension’s version number is the Claude Code version it bundles. Deny rules for that extension compare against that number, not a separate extension release number. - Sharing one lock file across operating systems. Platform-specific builds hash differently. Lock per OS. - Uninstalling and moving on. Cursor’s notice warns that uninstalling may not remove standalone persistence scripts. If the host check fires, run the vendor remediation steps and rotate credentials. Treat indicator lists as perishable. The two strings in the policy file belong to one incident. Add new ones from vendor notices as they appear, and date them in the reason field so a reviewer knows why each entry exists. Troubleshooting Ten problems show up often enough to list. - FileNotFoundError for the extensions folder. Cursor is installed somewhere unusual, or you have never launched it. Set CURSOR EXT DIR to the folder that holds the extension subfolders. - scanned 0 extensions on a machine full of them. The folder layout differs from what the inventory expects. Check that each subfolder contains a package.json at its top level. - cursor: command not found . The shell command is not installed. Install it from the Command Palette, open a new terminal, and retry. - PowerShell blocks the remediation script. The notice runs it with -ExecutionPolicy Bypass from an elevated prompt. Use the vendor command exactly, and only for the vendor script. - HOST PROC fires on a clean machine. A process listing includes the full command lines of every running process, so a shell or CI step that contains an indicator string matches. The message prints the matching line. If it is your own terminal or script, it is a false positive. - HASH drift right after an update. The extension changed on purpose. Review the new version, rerun lock , and commit the diff. - install fails with a version not found. Open VSX may not carry that exact build. Bump the entry to a version it does carry and relock. - The cooldown setting seems ignored. A team-level cooldown above 0 overrides per-user values. Ask an admin what the fleet value is. - recs reports a JSON error. The reader strips line comments and trailing commas but not block comments. Remove / / blocks or convert the file to plain JSON. - Permission denied reading authorized keys. The file belongs to another user or the machine hardens it. Run the check as the account that runs Cursor, not as root. Advanced Tips Once the basics run, a few upgrades pay off. Verify packages before you install them. When you install from a downloaded VSIX file, compute its SHA-256 with shasum -a 256 on macOS or Linux, or Get-FileHash on Windows, and compare it against the hash the publisher posts. Cursor’s October 29 notice lists hashes for the malicious packages, which shows how defenders use them. Review the lock file like code. A pull request that changes extensions.lock.json is a supply-chain change. Require a second reviewer and ask for a link to the release notes of each bumped version. Pair the cooldown with canaries. Keep one throwaway machine on the shortest cooldown and the others on a longer one. If something goes wrong in the canary, you learn before the rest of the team does. Export results into your SIEM. The --json flag on audit prints one object per finding, which feeds log pipelines without extra parsing. Alert on critical findings only and review the rest weekly. Extend the indicator list from vendor notices. Each time a vendor publishes indicators, add the file names, labels and addresses as new entries. For broader reading on editors and agent tooling, browse the software category https://shattered.io/category/software/ . Frequently Asked Questions Which Cursor extensions were compromised on October 29, 2026? Cursor identified Anthropic.claude-code versions 2.1.312 and 2.1.313 for macOS and Windows, available between about 2:27am and 1:35pm Pacific. Cursor said they were not developed by Anthropic and that other extensions in the marketplace were not affected. How do I check whether my machine was affected? Look for an SSH key labelled svc-telemetry in your authorized keys file. The macOS and PowerShell one-liners in Step 6 do exactly that. If the key is present, follow Cursor’s remediation steps and rotate credentials from a trusted system. Does uninstalling the extension fix it? Not by itself. Cursor warned that the payload dropped standalone persistence scripts that keep running after removal, so run the vendor remediation script and check for the key and tunnel processes. Where do Cursor extensions come from? According to Cursor’s help page, from the Open VSX registry through a Cursor-run marketplace proxy that applies automated malware and supply-chain analysis. Some Microsoft marketplace extensions that are missing from Open VSX are replaced by audited Anysphere builds. Is a cooldown enough to protect a team? No. A cooldown defers new versions, which helps against short-lived malicious uploads, but it does nothing for versions already installed. Combine it with an allowlist, signature verification where available, and the audit and lock workflow above. Does the kit work on Windows and Linux? The file scans, lock, verify and install commands are plain Python and run anywhere. The host check has macOS, Windows and Linux branches, but I only ran the tests on Linux, so verify the Windows branch on one machine first. Can I add other malicious extensions to the deny list? Yes. Add an entry with the extension ID, the exact versions, and a reason that cites the vendor notice. Leave the versions list empty to ban an ID outright, and add any indicator strings the notice publishes to ioc strings .