cd /news/ai-tools/cursor-extensions-audit-12-steps-60-… · home › topics › ai-tools › article
[ARTICLE · art-168096] src=shattered.io ↗ pub= topic=ai-tools verified=true sentiment=↓ negative

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.

by read32 min views10 publishedNov 12, 2026
Cursor Extensions Audit: 12 Steps, 60 Min [2026]
Image: Shattered (auto-discovered)

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 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.

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

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. 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, “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.

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.

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 over every file path and file hash in an extension folder, so Step 7 can detect any byte-level change.

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.

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.

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.

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:

$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 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.

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.

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.

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.

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.

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 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 theClaude Code VS Code documentation , 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. SetCURSOR_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 apackage.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, rerunlock , 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.

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @cursor 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/cursor-extensions-au…] indexed:0 read:32min 2026-11-12 · —