Sandboxed npm execution for MCP servers.
Runs Node.js / npm-based Model Context Protocol servers
inside an isolated Linux VM via Apple container,
with dynamic per-request filesystem scoping to the host process's working directory.
cargo install npxc --locked(--locked builds against the published Cargo.lock for a reproducible build.)
cargo install --path . --lockedOr build a release binary:
cargo build --release
# binary at: ./target/release/npxc- macOS (Apple Silicon — M-series chip required)
- Apple
containerCLI installed (releases). The runtime flags and isolation guarantees below were verified againstcontainer0.12.3. - Rust toolchain ≥ 1.87 (only required to build from source)
After installing container, run:
npxc doctorThis verifies the CLI is on your PATH and fully configures the container
system for you:
- Checks whether the
containersystem service is running. - If not running, starts it with
container system start --enable-kernel-install(which also installs the default kernel on first run). - If the service is already running but no default kernel is configured, runs
container system kernel set --recommendedto download and install one.
Running npxc doctor once after installation is all that is normally needed.
npxc is a transparent stdio proxy. Any tool or editor that lets you configure
an MCP server as a command works unchanged — just replace npx with npxc:
# Before
npx @scope/package-name
# After — same interface, sandboxed
npxc @scope/package-name
npxc @scope/package-name@1.2.3 # pin a specific version
npxc @scope/package-name -- --arg valIn any MCP client config that accepts a command + args, substitute accordingly:
{
"command": "npxc",
"args": ["@scope/package-name"]
}The MCP client sees the server as if it were a local process; the package actually runs inside an isolated VM with no network access and filesystem access scoped to the current working directory.
The repo includes examples/mcp_probe.rs, an interactive probe that runs
three scenarios against @sylphx/pdf-reader-mcp:
- Probe —
initialize+tools/list - Read PDF —
tools/callwith a local file (must be within CWD) - Scope test — attempt to read
/etc/passwd, expect a-32602rejection
# Build the binary first, then run the example
cargo build --release && cargo run --release --example mcp_probe
# Or pass a specific PDF path
cargo build --release && cargo run --release --example mcp_probe /path/to/file.pdf| Command | Description |
|---|---|
npxc <pkg-spec> [-- args...] |
Build (if needed) and run the MCP server |
npxc build <pkg-spec> |
Build the image without running |
npxc rebuild <pkg-spec> |
Force a --no-cache rebuild |
npxc list |
List all cached npxc/… images |
npxc clean <pkg-spec> |
Remove a specific cached image |
npxc clean --all |
Remove all cached images |
npxc inspect <pkg-spec> |
Print resolved config, image tag, mount plan, then exit |
npxc doctor |
Check that all prerequisites are present |
--config <path> Alternate config file (default: ~/.config/npxc/npxc.toml)
--cwd <path> Override the CWD scope (default: process working directory)
--no-isolate Disable path scoping; mount CWD read-only instead (escape hatch, warns loudly)
--log-level <lvl> trace | debug | info | warn | error (default: warn; to stderr only)
--dry-run Resolve config and print the plan, then exit (does not build or run)
| Code | Meaning |
|---|---|
0 |
Normal shutdown (client closed stdin) |
1 |
Configuration or argument error |
2 |
Container runtime not available |
3 |
Image build failure |
4 |
Runtime error (container died unexpectedly) |
130 |
Interrupted (Ctrl-C) |
Configuration files follow XDG conventions. On macOS the default location is
~/.config/npxc/.
~/.config/npxc/
├── npxc.toml # global defaults
└── packages/
├── sylphx-pdf-reader-mcp.toml # per-package overrides
└── ...
Per-package filenames are derived from the npm package name: lowercase,
replace @ and / with -, strip a leading -.
@sylphx/pdf-reader-mcp → sylphx-pdf-reader-mcp.toml.
[defaults]
node_image = "node:lts-slim" # base image for built images
container_cli = "container" # CLI name or path
network = "none" # "none" | "bridge"
memory = "512m"
cpus = "1"
mount_mode = "ro" # "ro" (recommended) | "rw"
log_level = "warn"
[paths]
# Order matters: strategies are tried in sequence; results are unioned.
strategies = ["config", "schema", "heuristic"]
[paths.heuristic]
absolute_prefix = true # args starting with "/" are treated as paths
home_prefix = true # args starting with "~/" are treated as paths
uri_prefix = ["file://"]package = "@sylphx/pdf-reader-mcp"
version = "0.4.2" # pinned; "latest" is allowed but discouraged
# Declare which arguments are filesystem paths, keyed by tool name.
# "*" applies to all tools.
[path_arguments]
"*" = ["path", "file", "filename", "input"]
"read_pdf" = ["path"]
"extract_pages" = ["path"]
# Declare arguments that must never be treated as paths (false-positive suppression).
[non_path_arguments]
"*" = ["url", "query", "pattern"]
# Optional per-package resource overrides.
[runtime]
memory = "1g"- Malicious package code. Runs inside an Apple
containerLinux VM with--network none, a read-only root filesystem (--read-only, with only atmpfsat/tmp), every Linux capability dropped (--cap-drop ALL), nonpm/npxat runtime, and a non-root user (USER node:node). - Broad filesystem access. The container's
/workspaceis populated dynamically: only files explicitly named in MCP tool calls (and only if they resolve within the host CWD) are ever visible to the package. The mount is read-only, so a package cannot write back through the published hard links to the host originals. (This is the default;--no-isolateinstead mounts the whole CWD read-only.) - Network exfiltration.
--network noneremoves all network interfaces (verified: outbound connections fail withENETUNREACH). - Persistence. Containers are ephemeral (
--rm). Nothing survives session end.
The filesystem boundary is the container mount, not the path heuristics: a file that
npxcfails to identify as a path is simply never published, so it stays invisible to the package. Path identification is a usability layer on top of a fail-closed boundary.
- Stdio exfiltration. A malicious package can include arbitrary content in MCP responses. The proxy does not filter output.
- LLM-driven enumeration. An LLM that calls a tool repeatedly to read many files under CWD is a behavioral problem outside the proxy's scope.
- Container / VM escape.
npxctrusts Applecontainer's isolation boundary. - TOCTOU on published files. The window between
canonicalizeand the hard link is not defended (requires a local attacker with write access).
Nearly every npm supply-chain attack follows the same kill chain: an attacker compromises a maintainer account (usually by phishing) or a CI token, ships malicious code inside an otherwise-normal package, and then — at install time or at runtime — (1) reads host secrets and files, (2) exfiltrates them over the network, and (3) persists or self-propagates.
npxc removes the capabilities each stage of that chain depends on. It moves
npm install into an ephemeral, isolated build VM (so install scripts never
touch your host filesystem, environment, or credentials), and it runs the server
with no network, no host environment variables, no host filesystem (only
explicitly-named in-CWD files, read-only), a read-only root, all
capabilities dropped, and as a non-root user. The notes below describe how
that posture maps onto specific, real incidents.
Honest scoping.
npxcis strongest for the large class of MCP servers that do local work (parsing, file analysis, format conversion), which should run with the default--network none. Tools that inherently need the network (sending email, calling an API) must opt intonetwork = "bridge", andnpxccannot stop a tool from misusing the network it was legitimately granted — it still contains filesystem, credential, and host damage, but covert exfiltration over an allowed connection is out of scope. Also note that thenpm installstep runs in an isolated VM but does have network (it must, to fetch the package); the protection there is that it is sandboxed away from your host, not that it is offline.
The most-downloaded compromise to date: a phishing email (from the look-alike
domain npmjs.help) tricked the prolific maintainer Qix into resetting 2FA,
and the attacker published malicious versions of ~18 foundational packages
— chalk, debug, ansi-styles, strip-ansi, color-convert, and more —
with a combined ~2–3 billion weekly downloads. The payload was a browser-side
crypto-clipper that hooked window.ethereum/fetch/XMLHttpRequest to swap
wallet addresses in transactions.
How npxc helps: this particular payload only activates in a browser, so it
would lie dormant in a Node MCP server. More generally, though, the lesson is
that any dependency can be silently replaced — and had the same maintainer
compromise shipped a Node-side stealer (the usual case below), npxc's
no-network, no-host-secrets sandbox would have left it nothing to steal and
nowhere to send it.
A self-replicating worm: on install, the payload downloaded TruffleHog, scanned
the filesystem and environment for secrets (NPM_TOKEN, GITHUB_TOKEN,
AWS_ACCESS_KEY_ID, …), probed cloud-metadata endpoints (169.254.169.254),
validated the stolen npm token, then used it to trojanize and republish other
packages the victim owned — propagating automatically — and exfiltrated
findings to a webhook and to attacker-created public GitHub repositories.
How npxc helps: the worm's entire premise is harvesting host/CI
credentials and republishing with a stolen token. Under npxc the install runs
in a throwaway VM with no ~/.npmrc, no host environment, no ~/.ssh, no host
cloud role, so the credential sweep comes up empty; and the runtime server has
no network and no npm token, so the self-propagation step (which must read a
token and publish over the network) simply cannot run.
A leaked CI publish token was used to ship malicious nx versions whose
postinstall script harvested GitHub/npm tokens, SSH keys, .env files, and
crypto wallets — even abusing locally-installed AI CLIs (claude, gemini,
q) to enumerate sensitive files — then exfiltrated everything to public
s1ngularity-repository repos under the victim's account. It also appended
sudo shutdown -h 0 to ~/.bashrc/~/.zshrc, bricking interactive shells.
How npxc helps: every target is on the host — ~/.npmrc, ~/.ssh,
.env, wallet files, the host's AI CLIs, and the host shell RC files. The
sandbox exposes none of them: no host filesystem, no host environment, no host
binaries, and a read-only mount, so the harvest finds nothing and the
shell-RC persistence/DoS can't touch the host. With no network at runtime, the
exfiltration to GitHub also fails.
The most on-the-nose example for npxc: a trojanized npm package that posed as a
Postmark email MCP server and silently BCC'd every email it sent to an
attacker-controlled address. Because it was an MCP server, it ran with whatever
trust the host gave it and exfiltrated mail in the normal flow of doing its job.
How npxc helps (and its limit): most MCP servers do local work and should
run with the default --network none, which turns "silently exfiltrates" into
"cannot reach any network at all." An email sender, however, genuinely needs the
network, so you would run it with network = "bridge" — and there npxc cannot
stop a BCC over the connection the tool was granted. What it still buys you: the
network is off by default and opt-in per package, and even when enabled the
server has no access to your host filesystem, credentials, or other tools'
data — so the blast radius is confined to the one capability you deliberately
granted.
@solana/web3.js(Dec 2024) — a phished maintainer pushed versions1.95.6/1.95.7that stole private keys to drain wallets. A key read and shipped to the attacker is exactly what the no-network sandbox blocks.ua-parser-js(2021) — account hijack delivered a cryptominer and a credential stealer. No network → no mining pool and no exfiltration; CPU caps and an ephemeral container limit the rest.node-ipc"protestware" (2022) — wiped/overwrote files for users in certain countries, geolocated by IP. With only read-only, in-CWD files visible, there is nothing for it to destroy.event-stream(2018) — a malicious transitive dependency targeted a specific wallet app's funds. Scoping plus no network removes both the data and the exfiltration path.
MIT