@happyberg/pkg-quarantine
v0.2.4
Published
Unified quarantine policy for package managers — block recently-published packages to prevent supply-chain attacks
Maintainers
Readme
pkg-quarantine
[ pkg ] —— wait 4 days ——> [ install ] ← supply-chain blocker
Block freshly-published packages before they reach your machine.
One command configures a release-age cooldown across every supported package manager on your machine. Malicious versions of hijacked packages are typically detected and pulled within hours to a few days, so a 4-day hold sits comfortably outside that window.
npm install -g @happyberg/pkg-quarantine
quarantine init
quarantine --version # 0.2.4That's it. For managers with native release-age support (npm, pnpm, bun, uv, yarn, deno), every install now silently rejects anything published in the last 4 days. For managers without it (pip, gem, composer, cargo, hex), quarantine update enforces the same policy at update time.
Deep dive: TanStack and the day provenance attestation stopped being a defense. Full breakdown of the May 11, 2026 attack chain, the defenses that failed, and the cooldown that held.
Why this exists
Supply-chain attacks are arriving in waves, the defenses we built for them are being defeated one by one, and a release-age cooldown is the layer that still holds.
The May 2026 wave
On May 11, 2026 between 19:20 and 19:26 UTC, 84 malicious npm artifacts across 42 @tanstack packages were published. Within 48 hours the same campaign covered 172 packages across npm and PyPI (Wiz, Snyk, Socket, Endor Labs). @tanstack/react-router alone has ~12M weekly downloads.
The detail that matters: the malicious versions were signed. Attackers chained a pull_request_target Pwn Request, GitHub Actions cache poisoning, and OIDC token extraction from runner process memory to publish through TanStack's legitimate trusted-publishing pipeline. Sigstore verified the artifacts. Provenance attestation showed them as authentic. They were indistinguishable from legitimate by every signature check the ecosystem built for this case.
This is wave 4 of the Shai-Hulud campaign:
- Sept 15, 2025: Shai-Hulud (original), self-replicating worm, hundreds of packages.
- Nov 2025: Shai-Hulud 2.0, 25,000+ malicious GitHub repos, Zapier / PostHog / Postman hit.
- March 2026: Trivy scanner npm packages, attributed to TeamPCP.
- March 31, 2026: axios (attributed to UNC1069),
plain-crypto-jstrojan, ~100M weekly downloads. - April 1-8, 2026: Asurion impersonation campaign (
sbxapps,asurion-hub-web,soluto-home-web,asurion-core), multi-stage credential harvester. - April 21, 2026: pgserve worm, cross-registry npm + PyPI, self-propagating.
- April 22, 2026:
@bitwarden/climalicious for 93 minutes, attributed to TeamPCP. - May 11-13, 2026: TanStack / Mini Shai-Hulud wave 4, 172 packages, signed by the legitimate pipeline.
What still works
Detection time. Socket flagged the TanStack artifacts within 6 minutes of publication. By the time anyone ran a fresh install long enough for the malicious versions to enter a dependency tree the second time, the security community had already pulled them.
A 4-day release-age cooldown closes the rest of the gap. Fresh installs of a brand-new version are held back until detection has caught up. It is one defense the May 2026 wave did not subvert, because it does not depend on trusting any party, signature, or attestation. It just waits.
The native settings exist:
| Manager | Setting |
|---------|---------|
| npm 11.10+ | min-release-age |
| pnpm | minimum-release-age |
| bun | install.minimumReleaseAge |
| uv | exclude-newer |
| yarn (per project) | npmMinimalAgeGate |
| deno (per project) | minimumDependencyAge |
They live in six config files with six different keys and six different units. pip, gem, composer, cargo, and hex still have no native install-time gate. And npm < 11.10 silently accepts min-release-age while ignoring it. Green config, zero protection.
pkg-quarantine configures all of them at once and verifies they are actually in effect.
Commands
quarantine init [managers...] # Write/merge quarantine configs
quarantine audit [managers...] # Traffic-light config report
quarantine status # Quick policy summary
quarantine update [managers...] # Quarantine-aware global updaterquarantine init
Writes quarantine settings to each manager's global config file. Existing settings and auth tokens are preserved; it merges, never clobbers.
quarantine init # All detected managers
quarantine init npm pnpm uv # Specific managers
quarantine init --dry-run # Preview without writing
quarantine init --days 7 # Custom quarantine periodquarantine audit
Checks current config against desired quarantine state. Traffic-light output:
npm ✓ configured min-release-age=4 days
pnpm ✓ configured minimum-release-age=5760 minutes
uv ✗ missing add exclude-newer to ~/.config/uv/uv.toml
pip ~ wrong value only-binary not setquarantine status
One-line-per-manager summary of quarantine posture.
quarantine update
Quarantine-aware global package updater. Checks each outdated package's publish date against the registry API before upgrading. Refuses to install anything that's too fresh unless you pass --force.
quarantine update # All managers
quarantine update npm # Just npm
quarantine update --dry-run # Preview without installing
quarantine update --force # Bypass quarantine (prints a warning)For AI agents
If you use Claude Code, Codex, Cursor, or any AI coding assistant that can install packages, this is especially for you.
AI agents install dependencies automatically, often without a human reviewing the exact version or publish date. That's fine for productivity. It's a supply-chain risk if the agent happens to install a freshly-hijacked package.
quarantine init enforces the policy at the package manager level, so it applies to every install, whether a human typed it or an agent did.
Setting it up once
# Install and configure everything:
npm install -g @happyberg/pkg-quarantine
quarantine initAfter that, npm install, pip install, uv add, etc. will all silently enforce the quarantine with no further action needed.
Instructing your agent
Add this to your CLAUDE.md (or equivalent agent instructions file):
## Package Security
A 4-day quarantine policy is enforced via pkg-quarantine.
Versions published less than 4 days ago will not install.
Rules:
- Never run bare `npm install -g <pkg>@latest`.
Use `quarantine update` instead.
- Before installing an unfamiliar package, check its
publish date.
- If an install fails with a quarantine error, report
it rather than bypassing it.
- The quarantine is a safety net, not an obstacle.Verifying your agent respects it
quarantine audit # Check all manager configs are set
quarantine status # One-line status summaryIf an agent tries to bypass quarantine with --ignore-scripts=false or --force, treat that as a signal to review what it's installing.
For CI pipelines
Add quarantine verification to your CI setup step:
- name: Verify quarantine policy
run: |
npm install -g @happyberg/pkg-quarantine
quarantine audit --exit-code # exits 1 if any manager is misconfiguredSupported managers
pkg-quarantine covers 13 package managers, but they fall into three honest tiers depending on what the underlying tool supports.
Tier 1: Native install-time quarantine
These managers ship a built-in release-age gate. init writes the setting and every install (manual or via an AI agent) is automatically protected.
| Manager | Mechanism | Notes |
|---------|-----------|-------|
| npm | min-release-age in ~/.npmrc | Requires npm ≥ 11.10.0 (Feb 2026). Earlier versions silently ignore the setting; quarantine audit warns. |
| pnpm | minimum-release-age (minutes) in pnpm rc | Config lives at ~/Library/Preferences/pnpm/rc on macOS, ~/.config/pnpm/rc on Linux (or $XDG_CONFIG_HOME/pnpm/rc). |
| bun | install.minimumReleaseAge (seconds) in bunfig.toml | — |
| uv | exclude-newer = "N days" in uv.toml | — |
| yarn | npmMinimalAgeGate in .yarnrc.yml | Per-project only. init prints the snippet to add. |
| deno | minimumDependencyAge in deno.json | Per-project only. init prints the snippet to add. |
Tier 2: Update-time quarantine via quarantine update
These managers have no native release-age config, so install-time enforcement isn't possible. quarantine update checks the registry API before upgrading and refuses fresh versions. Bare pip install foo / gem install foo / etc. are not gated; you must use quarantine update.
| Manager | Registry checked | Hardening init configures |
|---------|------------------|------------------------------|
| pip | PyPI JSON API | only-binary = :all: (blocks source-build attacks; not a quarantine itself) |
| gem | rubygems.org API | BUNDLE_TRUST___POLICY: MediumSecurity |
| composer | packagist API | no-scripts, allow-plugins={}, secure-http |
| cargo | crates.io API | Recommends cargo-audit |
| hex | hex.pm API | Recommends mix_audit |
Composer 2.9+ already enables
audit.block-insecureby default, soinitno longer writes that setting.
Tier 3: Audit and recommendation only
These managers don't have a release-age model at all. init prints best-practice recommendations and audit reports posture; there is no enforcement.
| Manager | What init does |
|---------|------------------|
| go | Verifies sumdb is active, recommends govulncheck |
| brew | Lists third-party taps as a posture warning |
Configuration
Global config at ~/.config/quarantine/config.toml:
quarantine_days = 4
managers = ["npm", "pnpm", "bun", "uv", "pip", "gem", "composer", "go", "brew", "cargo", "hex"]Defaults apply if the file doesn't exist: 4-day hold, all managers.
The default managers array lists 11 entries because yarn and deno are gated per-project (their release-age settings live in .yarnrc.yml and deno.json, not a global rc). pkg-quarantine still counts them in its "13 supported managers" headline, but quarantine init for those prints the snippet you add to the project, instead of writing global config.
Security and contributing
- Security issues: please follow the disclosure process in SECURITY.md.
- Contributions welcome: see CONTRIBUTING.md and the code of conduct.
Design
- 2 runtime dependencies:
commander+smol-toml. Zero transitive deps. - Dependency injection: All commands receive
FileSystemandShellinterfaces. Tests use in-memory mocks; no disk or network in tests. - Auth-token safe: The custom
.npmrcparser treats//lines as scoped registry entries (not comments), preserving all auth tokens intact. - Native
fetch(): Registry API calls use Node's built-in fetch. No HTTP library dependency. - Merge, never clobber:
initreads existing config before writing, preserving all unrelated settings.
Development
npm test # Run tests
npm run build # Build ESM + CJS
npm run lint # Type-check
npm run test:watch # Watch modeLicense
MIT.
