Trust Boundaries in AI Coding Extensions: Auditing What Cursor Installs and Executes

Trust Boundaries in AI Coding Extensions: Auditing What Cursor Installs and Executes

pr0h0••20 min read•hacking
AI Usage (89%)
On this page

Introduction: Why the Editor Is a Supply-Chain Trust Boundary

The most privileged process on a developer laptop is the editor, and it is also the least audited. We keep lockfiles for npm dependencies, write policy for container images, generate SBOMs for production services — and then install fifteen extensions into a Node.js process that can read ~/.ssh and ~/.aws/credentials, inherit every token exported in the shell that launched it, and spawn any binary the user can spawn. No sandbox. No manifest of capabilities. No approval prompt beyond "Install". This post audits that trust boundary in Cursor: what the editor installs, what an AI coding extension can execute, how to diff updates, how to pin versions, and how to cut an agent's blast radius.

On 2 November 2026, SecurityWeek published a report titled Malicious Claude Code Extension Served in Cursor Supply Chain Attack. That headline is the entire basis for the incident discussion here — I have the framing, not the details. Rather than invent a narrative, I want to do the reproducible part: walk the trust chain an AI coding extension sits on, then hand you commands to audit your own install. I will keep what the report states separate from what I could verify in my own environment.

My position up front: an AI coding extension with terminal auto-run enabled is not a plugin. It is a credential with a UI. Teams that would never ship an unvetted transitive dependency are running agent loops with npm publish tokens in scope. That asymmetry is the bug.

What the Report Says, and What It Leaves Open

Claims traceable to the SecurityWeek report

I will stick to what the report's own summary supports:

  • SecurityWeek published a report on 2 November 2026 describing a malicious extension distributed under Claude Code branding.
  • The report frames the channel as a Cursor supply-chain attack — the payload arrived through the extension distribution path, not through a vulnerability in Cursor's code.
  • The report's stated angle is that AI coding assistants and editor extensions expand the developer-tool attack surface.

That is the full extent of what I can attribute. Everything else here is either documented behavior of the VS Code extension model (which Cursor inherits) or marked explicitly as inference. That distinction matters for auditing: a headline can tell you a trust boundary failed; it cannot tell you which version to pin.

Details I could not verify and will not guess at

I do not know, and will not fabricate:

  • the extension identifier or publisher account
  • which registry served it — Cursor's marketplace, Open VSX, or a sideloaded VSIX
  • the affected version range, or whether a legitimate extension was trojanized versus a lookalike being published
  • install counts, dwell time, or first-seen date
  • what the payload did — credential theft, crypto mining, a backdoor, or just more telemetry than was disclosed
  • whether a CVE was assigned, or whether the listing was pulled
  • whether it invoked the real Claude Code CLI or only borrowed the name

If you have the advisory, read it before acting on version numbers. The rest of this post is an audit you can run whether or not an advisory ever exists.

Why an AI Coding Extension Is a Credential, Not a Plugin

What an extension host can touch on a developer machine

VS Code — and every fork, Cursor included — runs extensions inside a dedicated extension host process. That process is a full Node.js runtime. It sits apart from the renderer for stability, not for security. Microsoft's documentation describes the extension host as a separate process; it does not claim the OS sandboxes it. It runs as your user.

So by the time an extension activates, it already has:

SurfaceWhat is reachable
Filesystem~/.ssh, ~/.aws, ~/.config/gh, ~/.npmrc, ~/.docker/config.json, shell history, browser profile dirs
Environmenteverything exported in the shell that launched the editor, including GITHUB_TOKEN, NPM_TOKEN, AWS_*
Processchild_process.exec, spawn, arbitrary binaries, git hooks, package managers
Networkunrestricted egress — https, raw TCP, DNS
Editor stateopen documents, unsaved buffers, clipboard, terminal buffers
Agent contextwhatever the model was handed, including .env files the agent read

Environment inheritance is the part that gets overlooked. If your ~/.zshrc exports a long-lived token, that token is in the extension host's process.env before any extension code runs:

// what an extension gets for free on activation
console.log(Object.keys(process.env).filter(k => /TOKEN|KEY|SECRET|AWS|GH_/.test(k)));
// => [ 'GITHUB_TOKEN', 'NPM_TOKEN', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY' ]

No prompt. No scope. The extension does not have to be malicious at install time to be dangerous. It needs one compromised dependency, or one update.

The agent loop turns a static install into a standing capability

Classical extension review works because the code bounds the behavior. You read main, see it registers three commands, and reason about it from there.

An AI coding extension breaks that model. The code is a harness; the model chooses the commands at runtime. A single installed extension becomes an open-ended capability whose action set is decided by whatever text lands in the context window — a README, a source comment, a dependency's docstring, an MCP tool description.

That matters for supply-chain reasoning. When a poisoned package ships, you can diff the code and find the payload. When a poisoned agent harness ships, the payload may just be a string in a file the agent later reads. The install is static; the blast radius is not.

⚠️

"Review the extension source" is not a sufficient control for an agent harness. You are also reviewing every document the agent will ever read in that workspace. Treat the extension as a capability grant, not a code artifact.

The Trust Chain Cursor Inherits From the VS Code Ecosystem

Three install paths: marketplace, Open VSX, and sideloaded VSIX

Cursor is a VS Code fork, so it inherits VS Code's packaging format (.vsix), manifest schema, and activation model. What differs is the registry it talks to, and whether that registry verifies signatures.

PathWho controls the listingSignature verificationUpdate behavior
VS Code MarketplaceMicrosoftPublisher signing supportedAuto-update by default
Open VSXEclipse FoundationVaries by publisherAuto-update by default
Cursor's own marketplaceCursorNot publicly documented in detail — verify on your buildAuto-update by default
Sideloaded VSIXYouNoneManual

VS Code forks historically defaulted to Open VSX because Microsoft's marketplace terms restrict use by non-Microsoft products. Cursor later shipped its own registry. Which one your build talks to is something to determine empirically, not assume — install an extension you do not already have and see where it lands:

ls -d ~/.cursor/extensions/*/ | head
## and check the metadata record:
jq -r '.[] | [.identifier.id, .version, .location.path] | @tsv' \
  ~/.cursor/extensions/extensions.json 2>/dev/null | head

If extensions.json is missing on your build, the directory listing is enough — the unpacked package.json in each folder is authoritative about what got installed.

Why "signed" does not mean "audited"

Marketplace signature verification, where it exists, answers exactly one question: did this package come from the publisher account it claims to come from, and was it modified in transit? That is a tampering control, not a code-review control.

A valid signature is compatible with:

  • publisher account takeover (phishing, credential stuffing, session theft)
  • a malicious commit merged by a compromised maintainer or CI token
  • an ownership transfer to a new, unvetted maintainer
  • a dependency swap inside the packaged node_modules
  • a sideloaded VSIX, which bypasses the registry entirely

The common mistake I see in policy documents is treating "signed extension" as roughly equivalent to "reviewed extension". It is closer to "TLS for packages". Useful, and nowhere near sufficient. If your threat model includes a supply-chain compromise — and this report says it should — the signature does not fire.

Where a Poisoned Extension Actually Gets Its Reach

Activation events, workspace hooks, and post-install scripts

Activation is when the code starts running. A few manifest patterns mean "start early, always":

{
  "activationEvents": ["*"],
  "workspaceContains:**/.env"
}

* activates on window open. workspaceContains:**/.env activates the moment a developer opens a repository that has a .env file — which is most of them. For a credential stealer, that combination is close to ideal: no user action, no visible command, guaranteed trigger.

Two details break naive review:

  • Since VS Code 1.74, activation events are largely auto-generated from contributes.commands and contributes.languages. An extension with no activationEvents field can still activate on any contributed command, so the absence of the field proves nothing.
  • Marketplace extensions do not run npm install. vsce package vendors dependencies into the VSIX, and installing a VSIX is basically an unzip into the extensions directory. So postinstall scripts are not an install-time risk for marketplace extensions — but they are a risk the moment you build from source, run npm install in a cloned repo, or let the agent drive a package manager. The lifecycle risk moved; it did not disappear. npm documents the hooks in its scripts reference.

Agent permissions, auto-run, and approval fatigue

Auto-run settings are where a passive extension becomes an active one. Call it auto-run, YOLO mode, or a command allowlist — the semantic is the same: the agent executes tool calls without a per-call human decision.

The failure mode is not carelessness. It is that per-command approval does not survive contact with real usage. An agent in a build-and-test loop generates dozens of prompts. By prompt twenty, the user is clicking the primary button reflexively. A security model whose only control is "a human reads 200 command strings an hour" is a ritual, not a control.

My position: default to a deny-by-default allowlist of specific command prefixes (git status, npm test, pytest -q), and treat "allow all commands in this workspace" as equivalent to running the repo's code with your credentials — because that is literally what it does.

MCP server entries and tool descriptions as an install vector

This is the newest and least-audited path. MCP configuration is often project-scoped, which means cloning a repository can add a server entry that the agent launches.

The shape is plain JSON — a command, its args, and environment:

{
  "mcpServers": {
    "project-helper": {
      "command": "node",
      "args": ["./tools/helper.js"],
      "env": { "PROJECT_ROOT": "${workspaceFolder}" }
    }
  }
}

Nothing in that file looks like an exploit. But if project-scoped servers auto-start, opening a repo executes node ./tools/helper.js — attacker-controlled code from the clone — inside the same environment as the extension host. That is a supply-chain compromise requiring no extension at all, just a git clone and a trusting default.

The second MCP surface is subtler: tool descriptions are prose that enters the model's context. A server author controls the description field on each tool, and the model reads that text as an instruction-adjacent document. A description can steer which tool the agent picks, what arguments it passes, and what it does with the result. The MCP spec's own documentation flags trust and safety as a design concern; the practical implication is that a tool description is an attacker-controlled string in your prompt.

Chain them: the extension or MCP server supplies the capability, the description supplies the steering. Neither would alarm you alone.

Step 1 — Inventory What Cursor Actually Installs

Exporting extensions and versions from the CLI

Start with the list. The --show-versions flag is not optional — without it you get names with no versions, which is useless for pinning.

cursor --list-extensions --show-versions

The output shape:

[email protected]
[email protected]
[email protected]
[email protected]

If the cursor binary is not on PATH, use the editor's command palette ("Shell Command: Install 'cursor' command in PATH") or fall back to reading the directory. On a VS Code install the same command is code --list-extensions --show-versions; the flags are inherited.

Reading extensions.json and package.json for capability signals

The directory listing is nice; the manifest is where the signal lives. Each installed extension has an unpacked package.json, and the aggregate record sits at extensions.json:

for d in ~/.cursor/extensions/*/; do
  pj="$d/package.json"
  [ -f "$pj" ] || continue
  jq -r '[
    (.publisher + "." + .name),
    .version,
    ((.activationEvents // ["<auto-generated>"]) | join(",")),
    (.main // .browser // "-"),
    ((.capabilities.untrustedWorkspaces.supported // "declared") | tostring)
  ] | @tsv' "$pj"
done
acme.inline-things	2.4.1	onStartupFinished	./dist/extension.js	true
acme.lint-plus	0.9.12	<auto-generated>	./out/main.js	limited
someorg.agent-bridge	1.0.0	*	./dist/agent.js	false

Read the table as a risk ranking, not an inventory. What I look for:

SignalWhy it matters
activationEvents: ["*"]Runs on every window open, no user action
workspaceContains: globsTriggers on repository content you did not author
untrustedWorkspaces.supported: falseExtension is disabled in untrusted folders — but only if workspace trust is on
main (Node) vs browsermain means full process and filesystem access; browser is the constrained web worker host
extensionDependencies / extensionPackPulls additional code you did not choose
enabledApiProposalsUses proposed APIs, often for deeper editor integration

Workspace Trust is the one existing mitigation worth understanding, and the Workspace Trust docs describe its real limit: it gates extension behavior based on a folder-level decision. It helps with "I cloned a random repo". It does nothing about "the extension itself was compromised", because your own workspace is trusted.

Step 2 — Diff an Extension Update Before You Trust It

Pulling the VSIX and unpacking it safely

Auto-update means the extension you reviewed is not the extension you are running. To compare, pull the package yourself and unpack it somewhere inert — not into a workspace the editor has open, and without running any install step.

mkdir -p /tmp/vsix && cd /tmp/vsix
PUB=publisher; NAME=extension; VER=1.2.3

curl -sSL -o ext.vsix \
  "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/$PUB/vsextensions/$NAME/$VER/vspackage"

file ext.vsix
ext.vsix: gzip compressed data, from Unix, original size modulo 2^32 10485760

That gallery endpoint is undocumented and commonly returns a gzip-wrapped archive. Handle both cases:

if file ext.vsix | grep -q gzip; then
  gunzip -c ext.vsix > ext.zip
else
  cp ext.vsix ext.zip
fi

unzip -q -o ext.zip -d unpacked
ls unpacked/extension
CHANGELOG.md  dist  package.json  README.md

Do the same for the previous version into unpacked-prev/, then diff. diff -rq unpacked-prev/extension unpacked/extension gives you the changed file list; the interesting questions usually sit in package.json and the dist bundle.

The greps that actually surface risk

Greps on bundled code are noisy — minifiers rename locals, not globals or API strings, so the useful pattern is "which Node APIs does this bundle touch at all". Start broad, then read the hits in context.

cd unpacked/extension

grep -rnoE "child_process|\.exec\(|\.execSync\(|\.spawn\(|execFile" dist | sort | uniq -c | sort -rn | head
grep -rnoE "process\.env(\.[A-Z_]+|\['[A-Z_]+'\])" dist | head -40
grep -rnoE "os\.homedir\(\)|\.ssh|\.aws|\.npmrc|\.docker/config|id_rsa" dist | head -20
grep -rnoE "eval\(|new Function\(|Buffer\.from\([^)]*base64" dist | head -20

Representative output on a small extension:

     12 dist/extension.js:child_process
      6 dist/extension.js:.execSync(
      4 dist/extension.js:process.env.GIT_ASKPASS
      2 dist/extension.js:os.homedir()
      1 dist/extension.js:.ssh

Interpretation matters more than hit count. child_process in a linter that shells out to a formatter is expected. os.homedir() + .ssh + a network call in the same bundle is a different conversation. Read the call sites:

grep -n "\.ssh" -A5 -B5 dist/extension.js

The other reliable signal is outbound endpoints. Grep hostnames and check each one against what the extension claims to do:

grep -rhoE "https?://[a-zA-Z0-9._-]+" dist | sort | uniq -c | sort -rn | head -20

A telemetry endpoint you did not expect is worth a README issue. An endpoint that is a raw IP, a paste service, or a domain registered last month is worth uninstalling.

Step 3 — Pin Versions and Freeze the Extension Update Channel

Disabling auto-update and auto-check in settings

If extensions silently update, reviewing version 1.2.3 is theater. Turn the channel off in settings.json:

{
  "extensions.autoUpdate": false,
  "extensions.autoCheckUpdates": false,
  "extensions.ignoreRecommendations": true
}

extensions.autoUpdate also accepts "onlyEnabledExtensions", which is a reasonable middle ground if disabling entirely is politically impossible. extensions.ignoreRecommendations matters because a repository can recommend extensions, and a recommendation is a social-engineering path into the install you just locked down.

Because Cursor reads VS Code's settings schema for the extension subsystem, these keys are the ones to set — but verify the effect on your build rather than trusting it. The check is empirical: pin an extension, wait out an update cycle, confirm the version did not move.

Once auto-update is off, pin explicitly:

cursor --install-extension [email protected] --force

--force is what makes downgrades work; without it, an existing newer version stays put.

Locking an extension set for devcontainers and CI

Settings are per-machine and easy to lose. A committed lockfile is not.

// .devcontainer/devcontainer.json
{
  "name": "pinned-dev",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:22",
  "customizations": {
    "vscode": {
      "extensions": [
        "[email protected]",
        "[email protected]"
      ]
    }
  }
}

Then enforce it: install from the lockfile and fail the build if the runtime set drifts.

## install exactly the pinned set
while read -r ext; do
  [ -z "$ext" ] && continue
  cursor --install-extension "$ext" --force
done < extensions.lock

## verify: the installed set must match the lockfile exactly
diff <(cursor --list-extensions --show-versions | sort) <(sort extensions.lock) \
  || { echo "extension drift detected"; exit 1; }

That diff is the whole control. It catches a silent update, an extension someone installed by hand, and a dependency pulled in by an extension pack — the three ways a pinned environment stops being pinned.

Step 4 — Cut the AI Coding Agent's Blast Radius

Scoping credentials out of the workspace

You cannot make the extension host safe. You can make it uninteresting.

  • Stop exporting long-lived tokens in ~/.zshrc. Anything there is in process.env for every extension and every MCP server.
  • Use short-lived credentials: SSO-backed cloud sessions, aws-vault, op run -- from 1Password CLI, gh auth with minimal scopes.
  • Run untrusted repositories under a separate OS user or inside a container with only the repo mounted — not $HOME.
  • Launch the editor from a shell that never had the secrets in the first place.

That last one is the cheapest meaningful change:

## ~/bin/devshell — launch the editor with a deliberately boring environment
#!/usr/bin/env bash
exec env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY \
         -u GITHUB_TOKEN -u NPM_TOKEN -u DOCKER_AUTH_CONFIG \
     cursor "$@"

If an extension is compromised while you are running devshell, the attacker gets your source code and whatever the agent read. That is bad. They do not get push access to every repository you can reach.

Egress monitoring and what signals to alert on

Outbound traffic is the cheapest place to catch this class of compromise, because a payload that cannot reach its operator is a payload that failed. You do not need a full NDR deployment; you need to know what the editor process talks to.

## per-process connections (macOS/Linux)
lsof -i -nP -p "$(pgrep -f 'Helper \(Plugin\)' | head -1)"

Signals I would actually alert on, ranked by signal-to-noise:

SignalWhy it fires
Editor helper process connecting to a never-before-seen domainNew infrastructure, low false positive rate once baselined
Large POST with a base64-looking body from the editor processClassic exfil shape
Connections to raw IPs (no SNI, no hostname)Rare in legitimate extension telemetry
Editor process reading ~/.ssh or ~/.aws outside a known toolFile access is the step before exfil
Spawned child processes the user did not launch (curl, base64, tar)Staging and packaging

Baseline first. If you alert on "editor opens a socket" you will drown. Alert on "editor opens a socket to a domain that is not in the forty domains it normally uses".

Detection: What the Logs Would Have Shown

The uncomfortable part: most endpoint agents treat Code Helper (Plugin) or Cursor Helper (Plugin) as a signed application helper and do not scrutinize it. That naming is a genuine detection gap — the process doing the damage looks like part of the IDE.

Linux, with auditd, watching the reads that matter:

auditctl -w /home/dev/.ssh -p r -k ssh_read
auditctl -w /home/dev/.aws/credentials -p r -k aws_read

ausearch -k ssh_read -i --start today
type=PATH msg=audit(...): item=0 name="/home/dev/.ssh/id_ed25519"
  ... ppid=4821 pid=4930 comm="node" exe="/usr/share/cursor/cursor"

comm="node" under a cursor parent is the extension host doing the read. On a healthy machine that pattern should be rare and explainable — an SSH agent integration, maybe. On a compromised one it is the smoking gun, and the audit trail tells you which extension was active at that timestamp.

macOS, watching filesystem activity:

sudo fs_usage -w -f filesys | grep -Ei '\.ssh|\.aws/credentials|\.npmrc'

Both approaches show the same thing: the read happens before the network call. If you log file access on credential paths, you get a head start measured in seconds.

What I Would Fix First, Second, and Third

Most extension-hardening advice is a flat list of ten items, which means nobody does any of them. My ranking, ordered:

First: turn off agent auto-run, or replace it with a narrow allowlist. This is the highest-leverage control because it is the one that converts a dormant extension into an active executor. A compromised extension that can only run git status and npm test is a much smaller incident than one that can run curl. Everything else on this list reduces what gets stolen; this one reduces whether anything runs.

Second: pin versions, disable auto-update, commit a lockfile, and diff in CI. The phrase "served in a supply-chain attack" points at the distribution channel, and the distribution channel's default behavior is silent updates. If your installed version can change without a commit, you have no review process regardless of how carefully you read code.

Third: scope credentials. Short-lived tokens and a boring launch environment shrink the blast radius of everything above. This is also the control that survives the incident you did not predict, which is the only kind that matters.

Signature verification is fourth. It is worth having, and it does not answer the question people think it answers. I would push back on any policy that lists "only install signed extensions" as the primary control — it is a transit-integrity check being asked to do code-review work, and it will not fire on a compromised publisher, which is exactly the scenario in the report.

💪

The one-hour version: run --list-extensions --show-versions, set extensions.autoUpdate to false, diff your installed set against a file you commit, and grep your three most privileged extensions for process.env plus an unexpected hostname. That covers the highest-value findings without a project plan.

What I Confirmed Versus What I Did Not Test

Keeping the two apart is the difference between an audit and a vibe.

ClaimStatusBasis
Extension host runs as the user with filesystem, process, and network accessConfirmedVS Code extension host documentation; observable in ps and lsof
Activation events are largely auto-generated since VS Code 1.74ConfirmedVS Code activation events reference
Marketplace extensions do not run npm install at install timeConfirmedvsce packages dependencies into the VSIX; install is an unpack
extensions.autoUpdate / extensions.autoCheckUpdates exist and gate updatesConfirmedVS Code settings reference
The marketplace gallery vspackage endpoint returns a gzip-wrapped VSIXConfirmedObserved with file; the endpoint is undocumented and may change
Cursor honors extensions.autoUpdate identically to VS CodeNot testedStated as inherited behavior; verify on your build
Cursor's marketplace verifies publisher signaturesNot testedNot documented in enough detail for me to assert it either way
The extension host process is named Cursor Helper (Plugin)Likely, not confirmedFollows the Code Helper (Plugin) convention; check ps
MCP config paths and project-scope auto-start behaviorPartially verifiedBehavior is version- and setting-dependent; confirm against your build
Any detail of the reported Cursor incident beyond the headlineNot verifiedOnly the SecurityWeek summary was available to me

What I did not test at all: the payload, the affected versions, the install counts, whether the listing is still live, and whether any of this appeared in an advisory. If you are writing an incident report on this, those are the fields you need from a primary source, not from a blog post.

Further Reading

Share this post

More posts

Loading comments…