At 3 a.m., nobody reviews your coding agent's npm install. The agent decides it needs a package, runs the install, and moves on — no pause, no second look at the version it picked. That used to be a convenience story. After the last year of npm supply-chain worms, it's a threat model: an unattended installer with your credentials, your network access, and no instinct for suspicion.
The toll is concrete. The Shai-Hulud worm first surfaced in September 2025, when trojanized npm packages stole API keys, cloud credentials, and npm tokens and exfiltrated them to attacker-created GitHub repos — then used the stolen tokens to publish malicious versions of other packages, spreading worm-style. A second wave hit November 21–23, trojanizing packages from Zapier, ENS Domains, PostHog, and Postman via compromised maintainer accounts and malicious preinstall scripts. Socket's count for the two 2025 waves is roughly 800 npm packages.
The campaign never really ended: 2026 brought Mini Shai-Hulud sequels against SAP packages, intercom-client, and AntV (637 malicious versions across 323 packages in a single May morning, per Snyk), plus Wiz's "Miasma" cluster in Red Hat packages. As Andrew Nesbitt put it in April: if your agent can install arbitrary packages from public registries without approval, you've given the internet write access to your execution environment.
The short answer to the question this post asks — does install-time blocking belong in a self-hosted build pipeline as a default, unbypassable step? — is yes, but not the part you'd expect. The check that earns default status is the deterministic lockfile gate, not the interactive shim. Here's why, layer by layer.
Why scan-time is too late
Every mainstream dependency scanner shares the same fatal ordering: install first, report later. npm audit, Dependabot alerts, and the nightly CVE sweep all inspect packages that are already on disk — and, critically, whose lifecycle scripts have already executed. Shai-Hulud's payload rode on postinstall/preinstall scripts, which run during the install. A scanner that flags the package ten minutes later is performing an autopsy, not providing protection.
The ecosystem has noticed. npm 12, shipped in July 2026, disables install scripts by default — the registry's bluntest admission yet that execute-on-install was a mistake. That's a real improvement for the median developer, but it's a default, not a guarantee: scripts can be re-approved per package, other ecosystems (PyPI, crates.io, RubyGems) have no equivalent gate, and "no install scripts" does nothing about a package that is simply a known-vulnerable version of a legitimate dependency. The gap npm 12 leaves is exactly the one a gate fills:
| Approach | When it runs | What it catches | What it misses |
|---|---|---|---|
Post-hoc scanner (npm audit, Dependabot) | After install | Known CVEs in the tree | Anything whose damage happens at install time |
| Registry default (npm 12 no-scripts) | At install | Lifecycle-script execution (npm only) | Known-vulnerable versions, other ecosystems |
| Install-time gate | Before the real install runs | Known-vulnerable versions, before code executes | Zero-day malicious packages with no advisory yet |
The gate's job is narrow on purpose: if the version being requested already has a public advisory above your severity floor, the install never happens. Nothing hits disk, no script runs, and the caller gets a CVE ID plus a suggested safe version instead.
How Refuse actually works
Refuse is the open-source implementation of that gate, split into two pieces. The CLI (RefuseHQ/refuse-cli, written in Go) drops PATH shims in front of 18 package managers — npm, pnpm, yarn, bun, npx, pip, pip3, uv, poetry, pipenv, pdm, pipx, cargo, gem, bundle, go, composer, and dotnet — so every install/add/get is vetted before the real binary runs. The server (RefuseHQ/refuse, Apache-2.0) is a single Docker container: one Node process, one SQLite file in WAL mode, no Redis, no Postgres, no external state beyond the public feeds it ingests.
That ingestion is the interesting part. On first boot the server streams OSV's bulk archive across 26 ecosystems — npm, PyPI, Maven, Go, crates.io, RubyGems, plus the Debian/Ubuntu/Alpine/RHEL distro advisories — alongside CISA's Known Exploited Vulnerabilities catalog, FIRST's EPSS scores, GitHub Security Advisories, deps.dev metadata, and Wolfi advisories. The cold seed takes about three minutes; after that, OSV deltas land every five minutes and enrichment refreshes daily. A /readyz endpoint flips from 503 to 200 once every source has completed a pass, so an orchestrator never routes checks to a half-seeded database.
The blocked-install experience is one line:
$ npm install lodash@4.17.10
refuse: blocked — CVE-2019-10744 (high)
Prototype pollution in lodash <= 4.17.11
suggested safe version: 4.17.21Behind that line is a plain REST call — POST /api/v1/check/package with ecosystem, name, and version — which means anything that can speak HTTP can use the gate without the shim: a CI step, a curl in a build script, a custom admission check. The server also exposes batch, whole-lockfile, Dockerfile, and GitHub Actions workflow checks plus suggest-safe-version, and both the self-hosted image and the hosted refuse.dev edition speak the same API, so switching between them is a one-line config change.
The agent story: a gate the agent can't sweet-talk
Here's the detail that makes Refuse an agent-era tool rather than just a nicer npm audit. The CLI ships a Claude Code PreToolUse hook (refuse hook install claude-code), so the same policy gates installs the agent initiates — and the bypass asymmetry is deliberate. A human at the shell can append --no-refuse to skip the gate for one command. The agent hook ignores that flag, so an autonomous agent can't bypass its own gate, whether by instruction, accident, or prompt injection telling it to.
That asymmetry matters because agents fail this task in ways humans don't. The documented failure mode is slopsquatting: models confidently recommend packages that don't exist (react-dom-router, a misspelled reqests), attackers register those exact names on npm and PyPI, and the next agent that "installs the recommended package" pulls hostile code. One estimate making the rounds puts the nonexistence rate at roughly 1 in 5 AI-recommended packages. A June 2026 Cloud Security Alliance research note makes the enterprise version of the point: orgs running Codex, Claude Code, Copilot, and Cursor have introduced credentials that authenticate silently to production AI services, fetched through the same npm channel attackers spent 2025 and 2026 systematically targeting.
Be honest about what the hook does and doesn't cover, though. Only Claude Code has a shipped hook; Cursor, Continue, Aider, Codex CLI, and Cline are tracked but unimplemented, so today the agent story is one ecosystem deep. And advisory-based blocking can't catch a slopsquatted name with no advisory yet, or a fresh zero-day malicious release — that behavioral/malware-detection job belongs to a different layer (more on that below). What the hook guarantees is narrower but still valuable: your agent will never install a package the industry already knows is vulnerable, no matter what the prompt says.
Where the check belongs in a PaaS build pipeline
Now the core question: a git-push PaaS runs the tenant's own install command unattended at build time — npm ci inside a buildpack, pip install -r requirements.txt in a Dockerfile stage. Should the platform gate that install by default, or leave it as an opt-in tenant integration? Refuse's own docs give the answer structure, because the project is unusually frank about its bypass surface:
| Layer | Mechanism | Bypassable? | Verdict |
|---|---|---|---|
| Interactive shim | PATH wrapper on 18 managers | Yes — python -m pip, absolute paths, conda, any layer without the shim PATH | Convenience, not enforcement |
| Pre-commit hook | refuse-check scans changed lockfiles | Yes — client-side, --no-verify | Early feedback for humans |
| Lockfile gate in CI/build | refuse check-lockfile on the resolved manifest | No — inspects the manifest no matter how a dep arrived | Default, unbypassable build step |
| Dockerfile/workflow scan | refuse audit over repo | No, but advisory-only | Include alongside the lockfile gate |
| Registry proxy | Local HTTPS proxy for Buildkit/isolated runners | N/A — still preview/experimental | Watch, don't depend on yet |
The project's own README says it outright: the PATH shim is a convenient first line, but the deterministic gate is refuse check-lockfile. For a platform, that distinction is the whole decision. The shim can't survive a tenant Dockerfile that never inherits its PATH; the lockfile gate doesn't care how the dependency arrived, because it vets the resolved manifest — package-lock.json, uv.lock, Cargo.lock, go.sum, and a dozen more — after resolution and before the build proceeds. That's the shape a default takes: run it after dependency resolution, fail the build on a hit above the severity floor, and let the tenant's per-project .refuse.yaml allowlist record formally-accepted risks with reasons and expiries instead of a silent pass.
Two configuration details decide whether the default is real. First, Refuse fails open by default — server unreachable means a stderr warning and the install proceeds. A PaaS build step must flip fail_closed: true (or REFUSE_FAIL_CLOSED=1), or every blip in the advisory backend silently dissolves the gate. Second, the GitHub Action's fail-on-error input defaults to true, which is the correct posture for CI — match it in buildpacks. With those two set, the cost of the default is one fast local HTTP call per build against a self-hosted container the platform already runs, and the benefit is that no tenant app — and no tenant's deploy-from-chat agent — ever ships a build containing a known-exploited dependency without an explicit, expiring, logged exception.
What it doesn't do
No honest writeup ends at the previous section, because "blocks known-vulnerable installs" has a visible boundary: known. A typosquat registered yesterday, a maintainer account compromised this morning, a slopsquatted name hallucinated into existence — none of them have advisories yet, and Refuse will wave them through. That zero-day-malware layer is where the behavioral vendors live:
| Tool | Model | Catches zero-day malware? | Self-hostable? | Price shape |
|---|---|---|---|---|
| Refuse | Advisory lookup (OSV/KEV/EPSS/GHSA) | No | Yes — one container, Apache-2.0 | Free / hosted option |
| Socket Firewall | Behavioral analysis + risk signals, registry proxy | Yes — malware, typosquats, incl. transitives | No — cloud-backed | Free tier, then per-dev/month |
| depthfirst Dependency Firewall | Publish-time agentic analysis + policy (min age, licenses, quarantine) | Yes, by pre-assessment | No — vendor platform | Commercial |
npm audit / scanners | Advisory lookup after install | No | N/A | Free, but post-hoc |
This isn't a reason to skip the advisory gate — it's the reason to see it as the cheap, self-hostable floor rather than the ceiling. The known-vulnerable install is the embarrassing case: the CVE has a number, a fix version exists, and the only reason it shipped is that nothing said no. Refuse says no for the price of a container. Layer behavioral analysis above it when the threat model and budget justify it, not instead of it.
The remaining caveats are operational, not architectural: agent hooks beyond Claude Code are still tracked, the registry proxy that would cover Buildkit and isolated runners is explicitly preview, and the whole system is only as fresh as its feeds (five-minute OSV deltas are good; check /readyz before trusting a fresh deployment). None of these argues for opt-in. They argue for running the lockfile gate as the default while the sharper edges mature around it.
The default is the product
The through-line of the last eighteen months is that installs stopped being a human action. Agents run them, build pipelines run them, deploy-from-chat runs them — and every one of those callers is faster, more persistent, and less suspicious than the developer at the keyboard the old tooling assumed. npm disabling install scripts by default was the ecosystem's acknowledgment. An install-time gate that refuses the known-bad version before anything hits disk is the platform-level version of the same acknowledgment.
For a self-hosted PaaS, the economics are unusually clean: one container, no per-seat meter, a deterministic check on an artifact (the lockfile) the build already produces. Make it the default. Let tenants opt out with a logged, expiring exception — never opt in to being protected from a CVE the whole industry already knows about.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



