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.
Three layers, all small:
- 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. - Shell wrapper —
~/.config/pkg-guard/env.shexportsNPM_CONFIG_MIN_RELEASE_AGEandUV_EXCLUDE_NEWERfrom a rolling "N days ago" cutoff, then defines shell functions wrappingnpm/npx/uv/uvxso the cutoff is recomputed per command. This is what makesPKG_MIN_AGE_DAYS=0 npm install <pkg>work as a one-off. - 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-guardis 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 loadingenv.sh. See "Known gaps in the gate itself" below.The wrapper prints
pkg-guard: WARNINGon 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.
# 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-runFlags (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.
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.
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 = 3The 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.
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.
| 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.
- 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 realnpm 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 (onlyBuiltDependenciesin pnpm 10,allowBuilds/strictDepBuildsin pnpm 11), so projects should set their own lifecycle policy explicitly. For npm and yarn, the right pattern isignore-scripts=trueplus an explicit allowlist (esbuild,sharp, etc.). pkg-guard doesn't ship one — it's project-specific.
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.
- 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).
- 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'sexclude-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 --helpadvertises--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/uvbefore pkg-guard was installed is still installable from cache. Clear the cache if unsure. - Ephemeral runners.
pnpm dlxand (likely)bunxmay not readpnpm-workspace.yaml/ bunfig before fetching, so their installs should not be assumed gated. Prefernpx(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=0bypass — 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).
./install.sh --uninstall # bash / zsh
.\install.ps1 -Uninstall # PowerShellRemoves 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.
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 hooksThe 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."
MIT. Use freely, fork freely.