Safeguard
Supply Chain Security•

AI Agent Malware in 2026: It Comes Through npx, Not the Prompt

240 malicious npm and PyPI packages in 2026 carry AI-tool names. Most got in the old way and ran via MCP launch commands. The data and a config checker.

Safeguard Security Team
Security Research
14 min read

Most of the discussion about attacks on AI coding agents is about prompts: poisoned tool descriptions, injected instructions, a README that talks the agent into running something. The malware records from 2026 tell a plainer story. The packages that hit agent users this year got in the way npm and PyPI malware always has: stolen publish tokens, worms, names that copy real projects, and internal names claimed on the public registry. What's new is how often agents run them. An MCP config that says npx -y some-server installs and executes whatever that name resolves to, every time the client starts.

This post is for teams that let developers run Claude Code, Cursor, Codex, Gemini CLI or similar tools with MCP servers. It covers what OSV's malicious-package data shows for 2026, the cases worth knowing, why the launch command is the control that matters, and a script that reviews MCP configs.

The short answer

In OSV's malicious-package data for 2026, 240 of 14,481 npm and PyPI records have names that point at AI tooling, such as MCP servers, coding agents and LLM SDKs. That's 1.7%, but the share reached 3% or more in May, June and the first half of October. The cases include stolen-token releases of real packages (the Cline CLI, LiteLLM, Mistral's SDKs, @antv/mcp-server-chart), lookalike Claude Code packages, and ten unscoped mcp-server-* names claimed on one evening to catch npx launches. Prompt-level defenses don't touch any of these. Pin every MCP server to an exact version, prefer scoped names from the publisher you mean, add a release-age delay, and check configs against malware data.

What the data shows

OSV publishes every ecosystem's records as a download, including the MAL- entries from the OpenSSF malicious-packages project, which pulls reports from GitHub, Amazon Inspector, SafeDep, ReversingLabs, Socket and independent researchers. We counted 2026 records whose package names match AI-tool patterns. This script reproduces the count from the dumps. It reads the zip files and runs nothing from them:

#!/usr/bin/env python3
"""Count 2026 malicious-package records (OSV MAL-*) whose names point at AI tooling.

Input: OSV's per-ecosystem dumps, e.g.
  curl -O https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip
Usage: ai_malware_names.py npm=npm.zip PyPI=pypi.zip
Reads the zips only; executes nothing from them.
"""
import collections
import json
import re
import sys
import zipfile

PATTERNS = {
    "MCP server": r"(^|[^a-z])mcp([^a-z]|$)|modelcontextprotocol",
    "coding agent": r"claude|anthropic|opencode|codex|gemini-?cli|copilot|openclaw|clawd|cline|windsurf|kiro|aider",
    "LLM SDK": r"openai|langchain|llama-?index|ollama|hugging-?face|litellm|mistral|groq|deepseek|vllm",
}


def kinds(name):
    n = name.lower()
    return [k for k, p in PATTERNS.items() if re.search(p, n)]


def main(args):
    total = collections.Counter()
    hits = collections.Counter()
    months = collections.defaultdict(lambda: [0, 0])
    npx = []
    for arg in args:
        eco, path = arg.split("=", 1)
        with zipfile.ZipFile(path) as z:
            for member in z.namelist():
                if not member.startswith("MAL-"):
                    continue
                rec = json.loads(z.read(member))
                if not rec.get("published", "").startswith("2026"):
                    continue
                for aff in rec.get("affected", []):
                    name = aff.get("package", {}).get("name")
                    if aff.get("package", {}).get("ecosystem") != eco or not name:
                        continue
                    month = rec["published"][:7]
                    total[eco] += 1
                    months[month][0] += 1
                    ks = kinds(name)
                    if not ks:
                        continue
                    hits[(eco, "any")] += 1
                    months[month][1] += 1
                    for k in ks:
                        hits[(eco, k)] += 1
                    if re.search(r"\b(npx|uvx)\b", rec.get("details", "")):
                        npx.append((rec["published"][:10], eco, name))
    for eco in total:
        n = hits[(eco, "any")]
        print(f"{eco}: {total[eco]} records in 2026, {n} with AI-tool names ({100 * n / total[eco]:.1f}%)")
        for k in PATTERNS:
            print(f"  {k:13} {hits[(eco, k)]}")
    print("by month (all ecosystems): records, AI-named, share")
    for m in sorted(months):
        t, a = months[m]
        print(f"  {m}  {t:5} {a:4} {100 * a / t:4.1f}%")
    print(f"AI-named records whose analysis mentions npx or uvx: {len(npx)}")


if __name__ == "__main__":
    main(sys.argv[1:])

On the dumps as of 16 October 2026:

$ python3 ai_malware_names.py npm=npm.zip PyPI=pypi.zip
npm: 13022 records in 2026, 201 with AI-tool names (1.5%)
  MCP server    102
  coding agent  89
  LLM SDK       14
PyPI: 1459 records in 2026, 39 with AI-tool names (2.7%)
  MCP server    13
  coding agent  10
  LLM SDK       18
by month (all ecosystems): records, AI-named, share
  2026-01    619    5  0.8%
  2026-02    477    8  1.7%
  2026-03   1268    9  0.7%
  2026-04    805    6  0.7%
  2026-05   1864   64  3.4%
  2026-06   1607   49  3.0%
  2026-07   2222   32  1.4%
  2026-08   4180   35  0.8%
  2026-09    982   14  1.4%
  2026-10    457   18  3.9%
AI-named records whose analysis mentions npx or uvx: 25

Name matching is a blunt instrument. It misses AI-related malware with ordinary names, and it catches a few packages whose names only resemble AI tools. Some records are security-research canaries rather than criminal payloads. So read the share as a lower bound on how often attackers name a package after AI tooling, not as a measure of harm. Two things in it are still clear. MCP server names are the largest group. And the spikes line up with worm waves: May and June are the Mini Shai-Hulud campaign, which reached AI packages because AI packages are on npm.

The cases worth knowing

Date (2026)PackageWhat happened
17 Febcline 2.3.0 (npm)A stolen npm publish token pushed a release whose postinstall globally installed another package. Live for about eight hours. Cline now publishes through OIDC with provenance
24 Marlitellm 1.82.7 and 1.82.8 (PyPI)Malicious releases that sent secrets to an attacker domain. The OSV record says the project was compromised through a Trivy scan step in a GitHub workflow
11 and 12 Maymistralai (PyPI), @mistralai/mistralai and two sibling SDKs (npm)Malicious releases. OSV attributes the PyPI one to the Mini Shai-Hulud worm, which steals credentials and republishes every package the stolen tokens can reach
19 May@antv/mcp-server-chart and other @antv MCP serversThe OSV record attributes it to the same worm campaign. One compromised maintainer account (atool), 631 malicious versions across 314 packages in 22 minutes. The record says the payload also set up persistence through AI agent session hooks
19 to 22 May@bonsai-ai/claude-code, @cometix/claude-code, @tmecontinue/claudePackages copying Anthropic's Claude Code, with its README, author field or slash commands
9 JunTen unscoped names, mcp-server-github to mcp-server-supabaseClaimed within two minutes, each beaconing the installer's hostname and working directory. Amazon Inspector's analysis says the author described them as a security-research canary. The point stands: the names were free
23 Sep@memtensor/memos-cloud-openclaw-pluginAn attacker published a malicious version of an agent plugin carrying a Go implant that collects credentials from the home directory
4 Octanthropic-sdk (PyPI)Re-exports the real anthropic client and, from the third import on, fetches a remote script
13 and 16 Octopencode2, @beloved-lenora/opencode-*Sixteen packages named after the opencode agent, listed by GitHub as malware this week

Only the unscoped squats relied on how agents and their configs pick package names. The rest are ordinary supply-chain attacks that happened to land on AI packages. That's the argument in one table: the threat to agent users isn't mainly a new kind of malware. It's the old kind, run more often and with more access.

Why the launch command matters more than the prompt

Look at how MCP servers are usually configured. The client runs a command such as npx -y @scope/server, uvx some-server or bunx, often with no version. Three things follow:

  • Every launch can be an install. With no version pinned, the runner picks up a newer release whenever it resolves the name again: on a new laptop, after a cache clear, on a fresh CI runner. A poisoned release can reach you the next time the client starts, not the next time someone reviews a lockfile change.
  • -y removes the only prompt. npx -y installs without asking. Nobody sees a new package name go past.
  • The process runs as the developer. An MCP server gets the user's home directory, SSH keys, cloud credentials and model API keys, and often a GitHub token passed in by the config itself. That's exactly what the 2026 payloads collected.

Agents add one more path. The ten squats were described as targets for npx mcp-server-github typed by people and by AI coding agents. An agent asked to "add the GitHub MCP server" can produce the unscoped name as easily as a person can.

None of this is solved by prompt-injection defenses, tool-description scanning or an AI gateway. Those matter for a different attack, which we covered in MCP tool poisoning attacks. Install-path malware runs before the model sees anything.

Review your MCP configs

This script reads Claude Desktop, Claude Code .mcp.json, Cursor, VS Code and Codex config files. For each server started through npx, bunx, pnpm dlx or uvx, it flags launches not pinned to an exact version, -y on unpinned launches, and unscoped mcp-server-* npm names. With --osv, it asks OSV's public API whether the package has malicious releases, and whether a pinned version is one of them. It uses only the Python standard library:

#!/usr/bin/env python3
"""Review how MCP client configs launch servers through package runners.

Reads JSON configs (Claude Desktop, Claude Code .mcp.json, Cursor, VS Code)
and Codex TOML. Flags npx/bunx/pnpm dlx/uvx launches that aren't pinned to an
exact version, unscoped names that copy official scoped servers, and, with
--osv, package names that OSV lists as malicious. Usage:
  mcp_launch_check.py [--osv] CONFIG_OR_DIR...
"""
import json
import re
import sys
import tomllib
import urllib.request
from pathlib import Path

RUNNERS = {"npx": "npm", "bunx": "npm", "pnpx": "npm", "uvx": "PyPI", "pipx": "PyPI"}
NAMES = {".mcp.json", "mcp.json", "claude_desktop_config.json", "config.toml", "settings.json"}
EXACT_NPM = re.compile(r"^(@[^/]+/)?[^@]+@\d+\.\d+\.\d+([-+][\w.]+)?$")
EXACT_PY = re.compile(r"^[\w.\-\[\],]+==\d[\w.]*$")


def servers(path):
    text = path.read_text()
    data = tomllib.loads(text) if path.suffix == ".toml" else json.loads(text)
    block = data.get("mcpServers") or data.get("mcp_servers") or data.get("servers") \
        or data.get("mcp", {}).get("servers") or {}
    return block.items()


def package_arg(cmd, args):
    exe = Path(cmd).name
    if exe in ("pnpm", "yarn") and args[:1] == ["dlx"]:
        exe, args = "pnpx", args[1:]
    if exe == "uv" and args[:2] == ["tool", "run"]:
        exe, args = "uvx", args[2:]
    if exe not in RUNNERS:
        return None, None, False
    auto_yes = any(a in ("-y", "--yes") for a in args)
    i = 0
    while i < len(args):
        a = args[i]
        if a in ("--package", "-p", "--from"):
            return RUNNERS[exe], args[i + 1], auto_yes
        if not a.startswith("-"):
            return RUNNERS[exe], a, auto_yes
        i += 1
    return RUNNERS[exe], None, auto_yes


def bare(spec, eco):
    if eco == "npm":
        return re.sub(r"(?<!^)@.*$", "", spec)
    return re.split(r"[=<>!~\[]", spec)[0]


def osv_malicious(eco, name):
    """Return [(MAL id, affected versions or None for every version)]."""
    q = json.dumps({"package": {"ecosystem": eco, "name": name}}).encode()
    req = urllib.request.Request("https://api.osv.dev/v1/query", q, {"Content-Type": "application/json"})
    out = []
    for v in json.load(urllib.request.urlopen(req, timeout=20)).get("vulns", []):
        if not v["id"].startswith("MAL-"):
            continue
        for a in v.get("affected", []):
            whole = any(e.get("introduced") == "0" for r in a.get("ranges", []) for e in r.get("events", []))
            out.append((v["id"], None if whole else a.get("versions", [])))
    return out


def check(path, use_osv):
    out = []
    try:
        entries = servers(path)
    except (ValueError, tomllib.TOMLDecodeError, AttributeError):
        return out
    for name, cfg in entries:
        if not isinstance(cfg, dict) or "command" not in cfg:
            continue
        eco, spec, auto_yes = package_arg(cfg["command"], [str(a) for a in cfg.get("args", [])])
        if not eco or not spec:
            continue
        pkg = bare(spec, eco)
        pinned = bool((EXACT_NPM if eco == "npm" else EXACT_PY).match(spec))
        if not pinned:
            out.append(("WARN", name, f"{spec} is not pinned to an exact version, so each launch can resolve a new release"))
        if auto_yes and not pinned:
            out.append(("WARN", name, f"{cfg['command']} -y installs whatever {pkg} resolves to without asking"))
        m = re.match(r"^mcp-server-([\w.\-]+)$", pkg)
        if eco == "npm" and m:
            out.append(("CHECK", name, f"unscoped {pkg}: check the publisher; official servers "
                        "mostly use a scoped name, and squatters target these"))
        if use_osv:
            version = re.split(r"@|==", spec)[-1] if pinned else None
            for mal_id, versions in osv_malicious(eco, pkg):
                if versions is None:
                    out.append(("MALWARE", name, f"{pkg}: every version is malicious ({mal_id})"))
                elif version in versions:
                    out.append(("MALWARE", name, f"{spec} is a malicious release ({mal_id})"))
                elif not pinned:
                    out.append(("WARN", name, f"{pkg} had malicious releases {', '.join(versions)} ({mal_id}); "
                                "an unpinned launch then would have run one"))
    return out


def main(argv):
    use_osv = "--osv" in argv
    targets = [Path(a) for a in argv if a != "--osv"]
    files = []
    for t in targets:
        files += [p for p in t.rglob("*") if p.name in NAMES] if t.is_dir() else [t]
    order = {"MALWARE": 0, "WARN": 1, "CHECK": 2}
    rows = [(lvl, str(f), srv, msg) for f in sorted(files) for lvl, srv, msg in check(f, use_osv)]
    for lvl, f, srv, msg in sorted(rows, key=lambda r: (order[r[0]], r[1], r[2])):
        print(f"{lvl:7} {f} [{srv}] {msg}")
    print(f"{len(files)} config file(s), {len(rows)} finding(s)")
    return 1 if any(r[0] == "MALWARE" for r in rows) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

On a test laptop config, a repo with Claude Code, Cursor and Codex configs, and two real squatted names:

$ python3 mcp_launch_check.py --osv fx
MALWARE fx/laptop/claude_desktop_config.json [github] mcp-server-github: every version is malicious (MAL-2026-5479)
MALWARE fx/repo/.codex/config.toml [figma] mcp-server-figma: every version is malicious (MAL-2026-5477)
WARN    fx/laptop/claude_desktop_config.json [fetch] mcp-server-fetch is not pinned to an exact version, so each launch can resolve a new release
WARN    fx/laptop/claude_desktop_config.json [github] mcp-server-github is not pinned to an exact version, so each launch can resolve a new release
WARN    fx/laptop/claude_desktop_config.json [github] npx -y installs whatever mcp-server-github resolves to without asking
WARN    fx/laptop/claude_desktop_config.json [postgres] @modelcontextprotocol/server-postgres is not pinned to an exact version, so each launch can resolve a new release
WARN    fx/laptop/claude_desktop_config.json [postgres] npx -y installs whatever @modelcontextprotocol/server-postgres resolves to without asking
WARN    fx/repo/.codex/config.toml [figma] mcp-server-figma is not pinned to an exact version, so each launch can resolve a new release
WARN    fx/repo/.codex/config.toml [figma] npx -y installs whatever mcp-server-figma resolves to without asking
WARN    fx/repo/.mcp.json [chart] @antv/mcp-server-chart is not pinned to an exact version, so each launch can resolve a new release
WARN    fx/repo/.mcp.json [chart] npx -y installs whatever @antv/mcp-server-chart resolves to without asking
WARN    fx/repo/.mcp.json [chart] @antv/mcp-server-chart had malicious releases 0.10.10, 0.11.10 (MAL-2026-4069); an unpinned launch then would have run one
CHECK   fx/laptop/claude_desktop_config.json [github] unscoped mcp-server-github: check the publisher; official servers mostly use a scoped name, and squatters target these
CHECK   fx/repo/.codex/config.toml [figma] unscoped mcp-server-figma: check the publisher; official servers mostly use a scoped name, and squatters target these
4 config file(s), 14 finding(s)

The uvx mcp-server-fetch line is a warning, not malware: on PyPI that name belongs to the reference fetch server. Unscoped names are only suspicious on npm, where the reference servers are scoped. That difference is easy to get wrong when an agent writes the config.

What to change

  1. Pin exact versions in every MCP launch: npx -y @modelcontextprotocol/server-memory@1.0.0, uvx mcp-server-time==2026.10.10. Update them on purpose, the way you'd update a dependency.
  2. Add a release-age delay. npm has min-release-age (in days) since 11.10.0, and with npm 11.19.0 we checked that npx honors it from .npmrc. pnpm's minimumReleaseAge defaults to a day in pnpm 11. uv's exclude-newer takes a cutoff date. pnpm's documentation says most malicious releases are found and removed within an hour. Our guide to cooldown policies covers the trade-offs.
  3. Prefer scoped names from the publisher you mean, and check the npm page before adding an unscoped mcp-server-* package.
  4. Treat MCP configs as code. Keep project configs in review, and give agents a list of approved servers rather than letting them pick packages.
  5. Keep secrets out of the launch environment where you can, and rotate anything a machine held if it ran a listed release.

The fair view

The numbers are small. 240 records out of 14,481 is a niche, and many developers will never meet one of these packages. Some of the most-cited cases, like the June squats, were research beacons that collected hostnames, not credential stealers. And the strongest fix for stolen tokens, trusted publishing, is something maintainers such as Cline's have already adopted.

But security programs for AI tools are being built around the model: what it reads, what it's allowed to say, which tools it can call. The 2026 records point at the step before any of that, the command that fetches and runs a server. It's the least interesting part of an agent and the one most often left unpinned.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.