rawwerks/pkg-guard

Refuse npm/bun/pnpm/uv/yarn packages newer than N days. Defends against publish-and-detect supply-chain attacks.

★ 3Forks 0ShellGitHub ↗Compare

README

pkg-guard

A small, portable hardening kit that makes your machine refuse to install JavaScript or Python packages newer than N days (default: 3). Stops "publish-malware, get-detected-in-hours" supply-chain attacks like [email protected] and [email protected] from landing before the community catches them.

Supported package managers: npm, npx, pnpm, bun, yarn, uv, uvx. The manager has to be new enough to support its age-gate key; the installers print warnings for old versions.

What it actually does

Three layers, all small:

  1. Native config keys — writes each package manager's documented age-gate setting (min-release-age, minimumReleaseAge, npmMinimalAgeGate, exclude-newer) into the right config location. The templates only contain age-gate settings.
  2. Shell wrapper — ~/.config/pkg-guard/env.sh exports NPM_CONFIG_MIN_RELEASE_AGE and UV_EXCLUDE_NEWER from a rolling "N days ago" cutoff, then defines shell functions wrapping npm / npx / uv / uvx so the cutoff is recomputed per command. This is what makes PKG_MIN_AGE_DAYS=0 npm install <pkg> work as a one-off.
  3. Shell rc lines — appends a small block to ~/.bashrc / ~/.zshrc / ~/.bash_profile (whichever exist). If none exist, the installer creates the rc file that matches your current shell.

That's it. No daemons, no binaries, no cloud service. The installers run local package-manager commands for version checks and pnpm config, but do not intentionally call the network.

Posture. pkg-guard is base coverage for common agent, bot, and local automation flows, not a hardened sandbox. It gates resolution (not lockfile replay), can be bypassed by anyone who reads this README, and depends on shells loading env.sh. See "Known gaps in the gate itself" below.

The wrapper prints pkg-guard: WARNING on stderr whenever the gate is bypassed for a command. Grep for that string in local logs or agent transcripts to surface bypassed installs for review.

Install

# Linux / macOS / WSL (bash or zsh)
git clone https://github.com/rawwerks/pkg-guard.git && cd pkg-guard
./install.sh                          # idempotent, safe to re-run
# Windows (PowerShell 5.1 or 7+)
git clone https://github.com/rawwerks/pkg-guard.git; cd pkg-guard
.\install.ps1                         # idempotent, safe to re-run

Flags (both installers): --dry-run / -DryRun (print only), --force / -Force (overwrite with .bak.<ts> backups), --uninstall / -Uninstall (reverse). The installer does not overwrite existing config files by default. If you already have a ~/.npmrc or ~/.bunfig.toml (e.g. with auth tokens), it prints the snippet to merge in by hand. If an installed package manager is too old to support its age-gate key, installation continues but prints a warning.

Verify it's active

After installing, open a new shell or re-source your rc / profile.

bash / zsh:

echo $PKG_MIN_AGE_DAYS    # should print 3
type npm                  # should say "npm is a function"
type uv                   # should say "uv is a function"

PowerShell:

$env:PKG_MIN_AGE_DAYS     # should print 3
Get-Command npm           # CommandType should be 'Function'
Get-Command uv            # CommandType should be 'Function'

A real test: try installing a package published in the last hour, or set PKG_MIN_AGE_DAYS=99999 and try anything — the installer should refuse.

Bypass

When you've confirmed a fresh package isn't a hijack, disable the gate for that one invocation:

bash / zsh:

PKG_MIN_AGE_DAYS=0 npm install <pkg>
PKG_MIN_AGE_DAYS=0 npx <pkg>
PKG_MIN_AGE_DAYS=0 uv add <pkg>
PKG_MIN_AGE_DAYS=0 uvx <tool>

PowerShell (env var is sticky in the session, so reset it after):

$env:PKG_MIN_AGE_DAYS = 0
npm install <pkg>
$env:PKG_MIN_AGE_DAYS = 3

The wrapper prints pkg-guard: WARNING — age-gate BYPASSED for this command on stderr whenever this happens. Grep for that string in local logs or agent transcripts to surface bypassed installs for review.

Only npm and uv have rolling per-invocation bypass via PKG_MIN_AGE_DAYS=0. For pnpm, bun, and yarn the cooldown lives in their own config files — bypass means editing the config or passing the relevant CLI flag (see each manager's docs).

PKG_MIN_AGE_DAYS must be a non-negative integer. Invalid or negative values are reset to the safe default (3) with a warning.

Tuning the window

The default is 3 days. To change it permanently, set PKG_MIN_AGE_DAYS in your shell rc before the pkg-guard source line:

export PKG_MIN_AGE_DAYS=7
# >>> pkg-guard:shell-source >>>
[ -f "$HOME/.config/pkg-guard/env.sh" ] && . "$HOME/.config/pkg-guard/env.sh"
# <<< pkg-guard:shell-source <<<

Update the corresponding key in each native config file (units differ — that's on the package managers):

Manager Location Key Value Minimum support
npm / npx ~/.npmrc + wrapper env min-release-age / NPM_CONFIG_MIN_RELEASE_AGE days (integer) npm 11.10+
bun ~/.bunfig.toml minimumReleaseAge seconds (integer) installer feature-detects --minimum-release-age
pnpm global pnpm config + optional pnpm-workspace.yaml minimumReleaseAge minutes (integer) pnpm 10.16+
yarn ~/.yarnrc.yml npmMinimalAgeGate duration string (e.g. "3d") Yarn 4.12+
uv / uvx uv.toml + wrapper env exclude-newer / UV_EXCLUDE_NEWER duration in config, ISO timestamp in wrapper uv 0.2.12+

For pnpm, the installer uses pnpm config set minimumReleaseAge 4320 --global when pnpm is installed. It also prints the per-project pnpm-workspace.yaml snippet for repos that should carry their own policy. See configs/pnpm-workspace.yaml.template.

Cross-OS

OS Installer Shell
Linux install.sh bash / zsh
macOS install.sh bash 3.2 / zsh
WSL install.sh bash / zsh
Windows install.ps1 PowerShell 5.1 / 7+

The shell installer needs bash 3.2+ and one of GNU date, BSD date, or python3. The PowerShell installer (5.1+) writes uv config to %APPDATA%\uv\uv.toml; everything else lives at %USERPROFILE%\.thing. pnpm global config is written through pnpm config when pnpm is present.

Verified locally on Linux + bash. Windows/PowerShell paths are designed to work and have static coverage in the local test suite, but they still need real Windows smoke testing before claiming full parity.

Why these defaults

  • 3 days is the consensus minimum from Simon Willison's cooldown roundup. Both [email protected] and [email protected] were yanked within hours, well inside that window.
  • pkg-guard gates resolution, not lockfile replay. The gate fires when a manager picks a version. Lockfile-replay commands (npm ci, pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile, uv sync) install pinned versions without re-resolving, so the gate never fires. In automation that only replays lockfiles, pkg-guard is effectively a no-op. Enforce it on the lockfile-write path instead: developer machines and the bots/PRs that perform real npm install / npm update / scheduled Renovate runs — where a malicious-but-fresh version would first enter the lockfile.
  • Lifecycle scripts: per ecosystem. bun is default-secure — no install scripts run unless allowlisted via trustedDependencies. pnpm has moved across versions here (onlyBuiltDependencies in pnpm 10, allowBuilds / strictDepBuilds in pnpm 11), so projects should set their own lifecycle policy explicitly. For npm and yarn, the right pattern is ignore-scripts=true plus an explicit allowlist (esbuild, sharp, etc.). pkg-guard doesn't ship one — it's project-specific.

Other ecosystems

pkg-guard only configures package managers with a documented age-gate setting today. For everything else, closest existing options:

Ecosystem Tool What it is Install
Rust (cargo) cargo-cooldown Wrapper that downgrades fresh crates via cargo update --precise. cargo install cargo-cooldown
Go modules gomod-age CI-side check that scans go.sum and fails on fresh dependencies. Not a runtime install gate. go install github.com/fchimpan/gomod-age@latest
Ruby (gem/bundler) gem.coop Alternative gem registry that serves a 48-hour-delayed view of rubygems.org. Set source "https://gem.coop" in your Gemfile

These are upstream-community tools, not part of pkg-guard — but the installer prints a one-line hint for whichever applies to your machine. Upstream tracking issues are listed in CONTRIBUTING.md.

What it does NOT protect against

  • Compromised packages older than the cutoff (rare — most attacks are caught fast).
  • Compromised registries (use lockfile integrity hashes).
  • Stolen developer credentials publishing as you (use 2FA + provenance).
  • Typo-squatting (review what you install).
  • Already-installed compromised versions (audit your lockfiles).

Known gaps in the gate itself

  • Lockfile-replay installs (npm ci, pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile, uv sync) skip resolution — pkg-guard does not re-check publish age for already-locked versions. The gate has to fire on the machine that writes the lockfile.
  • Non-interactive shells. bash -c "npm install foo" does not source ~/.bashrc, so the wrapper functions and one-command bypass behavior are absent. Native config still applies for managers that read it, including uv's exclude-newer = "3 days" fallback.
  • Unsupported or old package-manager versions. Older tools may ignore the age-gate key. The installers warn when they can detect this, but they cannot make an old package manager enforce a setting it does not support. Bun detection is especially lightweight: the installer checks whether bun install --help advertises --minimum-release-age. If Bun changes that help text, local tests will not catch the upstream behavior change; verify Bun release notes when upgrading.
  • Unsupported shells. The shell wrapper targets bash and zsh. Fish, csh, plain sh, and custom non-login/non-interactive environments need their own sourcing or native config only.
  • Already-cached packages. A malicious version that landed in ~/.npm, ~/.bun/install/cache, or ~/.cache/uv before pkg-guard was installed is still installable from cache. Clear the cache if unsure.
  • Ephemeral runners. pnpm dlx and (likely) bunx may not read pnpm-workspace.yaml / bunfig before fetching, so their installs should not be assumed gated. Prefer npx (covered by the shell wrapper) or a fully installed dev dependency.
  • Agent prompt-engineering. A determined AI coding agent can read this README and discover the PKG_MIN_AGE_DAYS=0 bypass — the syntax is documented above. pkg-guard names this as a known limit rather than trying to solve it. If you need to defend against an adversarial agent loop, gate the agent's environment at a layer pkg-guard doesn't operate at (sandbox, no-network, code review, mandatory human approval).

Uninstall

./install.sh --uninstall          # bash / zsh
.\install.ps1 -Uninstall          # PowerShell

Removes the shell rc / profile source line and ~/.config/pkg-guard/env.{sh,ps1}. Leaves your native config files alone — edit them yourself to remove the hardening keys.

Contributing

PRs welcome — especially to add package managers we missed. See CONTRIBUTING.md.

Local validation is intentionally local-only; there is no GitHub Actions or cloud CI requirement:

./tests/test.sh       # fast deterministic harness, no network, temp HOME only
./setup-hooks.sh      # installs local pre-commit and post-commit hooks

The pre-commit hook is the blocking gate: it runs ./tests/test.sh and then gitleaks on staged changes. The post-commit hook re-runs ./tests/test.sh as a final local signal. Git does not let post-commit reject an already-created commit, so treat a post-commit failure as "fix immediately or amend/revert."

License

MIT. Use freely, fork freely.

Contributors

rawwerks

Issues