Skip to content

Commit ba02811

Browse files
committed
Add pip and pipx cooldowns
1 parent 85b4eb8 commit ba02811

8 files changed

Lines changed: 179 additions & 20 deletions

File tree

changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8-
- [mpm] Add a `--cooldown DURATION` option and `[mpm] cooldown` config key that refuse to install or upgrade any package version published more recently than the given release age (like `7 days` or `1 week`), as a mitigation against supply-chain attacks. Enforced through each manager's native release-age mechanism: `uv` and `uvx` (`UV_EXCLUDE_NEWER`) and `npm` (`before`). Managers without native support are skipped during install and upgrade by default (fail-closed); pass `--allow-no-cooldown` (or set `[mpm] allow_no_cooldown`) to run them without the safeguard.
8+
- [mpm] Add a `--cooldown DURATION` option and `[mpm] cooldown` config key that refuse to install or upgrade any package version published more recently than the given release age (like `7 days` or `1 week`), as a mitigation against supply-chain attacks. Enforced through each manager's native release-age mechanism: `uv` and `uvx` (`UV_EXCLUDE_NEWER`), `npm` (`before`), and `pip` and `pipx` (`PIP_UPLOADED_PRIOR_TO`, requires pip `26.1`). Managers without native support are skipped during install and upgrade by default (fail-closed); pass `--allow-no-cooldown` (or set `[mpm] allow_no_cooldown`) to run them without the safeguard. See the new [Release-age cooldown](https://kdeldycke.github.io/meta-package-manager/cooldown.html) docs page for the per-manager support table.
99
- [mpm] Fix `pkg:cpan/…`, `pkg:guix/…`, and `pkg:nix/…` pURL specifiers raising `Unrecognized pURL type` even though those managers are implemented. The `cpan`, `guix`, and `nix` entries in `PURL_MAP` were set to `None`, which shadowed the manager-id fallback in `Specifier.parse_purl()`; they now map to their respective managers.
1010
- [mpm] Add a `scope` attribute (`ManagerScope.SYSTEM` or `ManagerScope.PROJECT`) to `PackageManager` to distinguish system-wide installers from project-local dependency managers, plus a `discover_projects()` extension point reserved for the latter. All maintained managers are system-scoped; project scope is not supported yet.
1111
- [zerobrew] Add `upgrade` and `upgrade_all` support by wrapping the `zb upgrade` command introduced in zerobrew `0.3.0`, and bump the minimum required `zb` version from `0.2.0` to `0.3.0`. `0.3.0` also fixes the Linux Python install failures reported in https://github.com/lucasgelfond/zerobrew/issues/336, so the `zerobrew` install tests now run as stable on both x86 and ARM Linux.

docs/cooldown.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# {octicon}`shield-check` Release-age cooldown
2+
3+
`mpm` can refuse to install or upgrade any package version younger than a chosen release age. This is a supply-chain safeguard: malicious releases (compromised credentials, dependency confusion, account takeover) are typically detected and pulled from registries within days of publication, so a short waiting period keeps the most recent and most likely compromised versions off the system.
4+
5+
Recent examples include the [XZ Utils backdoor](https://en.wikipedia.org/wiki/XZ_Utils_backdoor) and recurring [vulnerabilities in the VS Code extension marketplace](https://www.wiz.io/blog/supply-chain-risk-in-vscode-extension-marketplaces). A delay of even a few days would have given the community time to react.
6+
7+
## Quick start
8+
9+
The cooldown applies to every install and upgrade `mpm` performs:
10+
11+
```{code-block} shell-session
12+
$ mpm --cooldown "7 days" upgrade --all
13+
$ mpm --cooldown "1 week" install some-package
14+
$ mpm --cooldown 12h --allow-no-cooldown upgrade --all # let unsupported managers run too
15+
```
16+
17+
It accepts a human-readable duration like `7 days`, `1 week`, `12h`, `30m`, a bare number of days, or `0` / empty to disable. The value is also settable as the `cooldown` key in any `mpm` configuration file (see {doc}`configuration` for the full schema) or as the `MPM_COOLDOWN` environment variable.
18+
19+
## How it works
20+
21+
When `cooldown` is set, `mpm`:
22+
23+
1. Computes a UTC cutoff timestamp equal to `now - cooldown`.
24+
2. For each manager that natively enforces a release-age gate (see the support table below), injects the manager's dedicated environment variable carrying that cutoff into every CLI call. The manager's own resolver then excludes every version published after the cutoff, including transitive dependencies.
25+
3. For each manager without a native gate, **skips** install / upgrade with a warning (fail-closed). Pass `--allow-no-cooldown` (or set `allow_no_cooldown = true` in the config file) to run those managers anyway, without the safeguard.
26+
4. Leaves read-only operations (`outdated`, `installed`, `search`) untouched: information is never blocked, only mutations are.
27+
28+
The choice to delegate to each manager's own resolver rather than reimplement the gate inside `mpm` is deliberate: only the resolver can apply the cutoff to the whole dependency closure (see [Limitations](#limitations) below).
29+
30+
## Supported managers
31+
32+
The table below is the source of truth for which managers `mpm` can gate today and the state of the upstream effort everywhere else. Statuses:
33+
34+
- **Enforced**: `mpm` actively injects a cooldown environment variable on every CLI call. Listed in the [`cooldown_env_var`](#how-it-works) framework.
35+
- **Shipped upstream**: the manager ships a release-age gate but `mpm` does not (yet) plug into it.
36+
- **Proposed**: an open pull request, RFC, or issue is on file upstream.
37+
- **None**: no public proposal found.
38+
- **N/A**: the concept does not apply (distro-curated repositories with their own staging, archived projects, meta-upgraders, ...). A structural equivalent is noted when relevant.
39+
40+
| `mpm` id | Status | Mechanism | Reference |
41+
| :--- | :--- | :--- | :--- |
42+
| `apk` | None |||
43+
| `apm` | N/A (archived June 2022) || [atom/apm](https://github.com/atom/apm) |
44+
| `apt` | N/A (Debian's `unstable``testing``stable` migration is functionally similar) || [Nesbitt, *Package managers need to cool down*](https://nesbitt.io/2026/03/04/package-managers-need-to-cool-down.html) |
45+
| `apt-mint` | N/A (follows `apt`) |||
46+
| `brew` | Proposed (closed as not planned for users; merged for internal bottle resource resolution) | (internal) `--min-release-age=1`, `--uploaded-prior-to` | [Homebrew/brew#21129](https://github.com/Homebrew/brew/issues/21129) |
47+
| `cargo` | Proposed (RFC 3923 merged, nightly implementation) | `-Zmin-publish-age` | [rust-lang/cargo#17009](https://github.com/rust-lang/cargo/issues/17009) |
48+
| `cask` | Same as `brew` (inherits) || [Homebrew/brew#21129](https://github.com/Homebrew/brew/issues/21129) |
49+
| `choco` | None |||
50+
| `composer` | Proposed | open PR adds `cooldown` | [composer/composer#12692](https://github.com/composer/composer/pull/12692) |
51+
| `cpan` | None |||
52+
| `deb-get` | None |||
53+
| `dnf` | None (effort focused on `dnf5`) |||
54+
| `dnf5` | Proposed | `minimum_package_age` (open issue) | [rpm-software-management/dnf5#2743](https://github.com/rpm-software-management/dnf5/issues/2743) |
55+
| `emerge` | None |||
56+
| `eopkg` | None |||
57+
| `flatpak` | None |||
58+
| `fwupd` | N/A (LVFS staged deployment) || [LVFS news](https://lvfs.readthedocs.io/en/latest/news.html) |
59+
| `gem` | Proposed (Bundler PR open) | `--cooldown` / `BUNDLE_COOLDOWN` / per-source `cooldown:` | [ruby/rubygems#9576](https://github.com/ruby/rubygems/pull/9576) |
60+
| `guix` | None |||
61+
| `macports` | None |||
62+
| `mas` | None |||
63+
| `nix` | None |||
64+
| **`npm`** | **Enforced** | `before` env `npm_config_before`; the newer `min-release-age` (npm ≥ 11.10) is the same idea spelled relative | [npm docs](https://docs.npmjs.com/cli/v11/using-npm/config#before) |
65+
| `opkg` | None |||
66+
| `pacaur` | None (Arch AUR helper) |||
67+
| `pacman` | None |||
68+
| `pacstall` | None |||
69+
| `paru` | None (Arch AUR helper) |||
70+
| **`pip`** | **Enforced** (pip ≥ 26.1) | `--uploaded-prior-to` env `PIP_UPLOADED_PRIOR_TO` | [pypa/pip#13674](https://github.com/pypa/pip/issues/13674) |
71+
| **`pipx`** | **Enforced** (via pip's env var; needs the underlying pip ≥ 26.1) | inherits `PIP_UPLOADED_PRIOR_TO` | [pypa/pipx#1811](https://github.com/pypa/pipx/issues/1811) |
72+
| `pkg` | None |||
73+
| `ports` | None (FreeBSD ports) |||
74+
| `pwsh-gallery` | None |||
75+
| `scoop` | Proposed | open feature request | [ScoopInstaller/Scoop#6513](https://github.com/ScoopInstaller/Scoop/issues/6513) |
76+
| `sdkman` | None |||
77+
| `sfsu` | Inherits from `scoop` |||
78+
| `snap` | N/A (risk channels `stable`/`candidate`/`beta`/`edge`, plus `snap refresh --hold` up to 90 days) | `snap refresh --hold` | [Snap docs](https://snapcraft.io/docs/how-to-guides/manage-snaps/manage-updates/) |
79+
| `steamcmd` | None |||
80+
| `stew` | None |||
81+
| `topgrade` | N/A (meta-upgrader; delegates to each underlying manager) |||
82+
| **`uv`**, **`uvx`** | **Enforced** | `exclude-newer` env `UV_EXCLUDE_NEWER` | [uv docs](https://docs.astral.sh/uv/reference/settings/#exclude-newer) |
83+
| `vscode`, `vscodium` | Proposed | proposed enterprise policy | [microsoft/vscode#316867](https://github.com/microsoft/vscode/issues/316867) |
84+
| `winget` | Proposed | open feature request | [microsoft/winget-cli#6178](https://github.com/microsoft/winget-cli/issues/6178) |
85+
| `xbps` | None |||
86+
| `yarn` (Classic v1) | None (project in maintenance mode) || [yarnpkg/yarn](https://github.com/yarnpkg/yarn) |
87+
| `yarn-berry` | Shipped upstream (Berry ≥ 4.10) but unreachable through `mpm` (the `yarn-berry` handler does not implement `install` / `upgrade` because Yarn Berry removed global installs) | `npmMinimalAgeGate` | [Yarn settings](https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate) |
88+
| `yay` | None (Arch AUR helper) |||
89+
| `yum` | N/A (deprecated alias for `dnf` on RHEL-family) |||
90+
| `zerobrew` | None |||
91+
| `zypper` | None |||
92+
93+
### Notes
94+
95+
- **`brew`** ships an internal release-age gate inside Homebrew's bottle resource-resolution pipeline (merged in [`Homebrew/brew#21919`](https://github.com/Homebrew/brew/pull/21919)) so formulae built from upstream resources get a 24-hour delay automatically. There is **no** user-facing knob; the issue requesting one was closed as not planned.
96+
- **`pip`** silently no-ops on releases older than `26.1`: the `PIP_UPLOADED_PRIOR_TO` env variable is unrecognized and ignored. Treat the gate as "best effort" until `mpm` learns to refuse injection on a stale pip (see future directions).
97+
- **`yarn-berry`**: the gate works in Berry `≥ 4.10`, but Yarn Berry removed `yarn global`, so `mpm`'s `yarn-berry` handler only implements `search`. Onboarding `npmMinimalAgeGate` would not change anything reachable through `mpm`.
98+
- **`apt`, `snap`, `fwupd`** all have *structural* delays (Debian's migration windows, Snap risk channels, LVFS staged deployment) rather than per-version age gates. They're marked N/A because the underlying ecosystem solves the problem in a different shape.
99+
- **The Arch AUR helpers (`pacaur`, `paru`, `yay`)** and most distro front-ends inherit whatever delay the underlying repository / AUR provides; none of them ship a dedicated cooldown setting.
100+
101+
## Limitations
102+
103+
### The transitive-dependency gap
104+
105+
`mpm`'s cooldown is exactly as good as the underlying resolver's. For managers that install with a real dependency resolver (PyPI, npm, ...), the native mechanism applies the cutoff to the whole tree, including transitive dependencies. For managers without a native mechanism, `mpm` cannot retrofit one without reimplementing the resolver: pinning only the top-level package would leave transitive dependencies fresh, which is precisely the most common attack vector. That is why unsupported managers are fail-closed rather than fail-open.
106+
107+
### Coverage limits
108+
109+
Distro and system managers (`apt`, `dnf`, `pacman`, `brew`, ...) generally have no per-upstream publish date attached to a package version: their version string is the distro maintainer's package build, not the upstream release, and the threat model differs (curated repositories with their own staging and review). The concept does not cleanly map. These managers are listed in the support table as N/A.
110+
111+
### npm and `min-release-age` coexistence
112+
113+
`mpm` enforces the cooldown for `npm` by injecting `npm_config_before`. On `npm` 11.x releases predating [`npm/cli#9368`](https://github.com/npm/cli/pull/9368), combining `before` with a `min-release-age` setting already present in the user's `.npmrc` raises an error. Either upgrade `npm` or pick one of the two mechanisms.
114+
115+
### Read-only consistency
116+
117+
The `outdated` report is not filtered by the cooldown on unsupported managers, so it may list versions that the subsequent `upgrade` would skip. For supported managers the same environment variable also affects `outdated`, so the report and the upgrade stay consistent.
118+
119+
## Possible future directions
120+
121+
- **Detect the underlying `pip` version at runtime.** Today `mpm` injects `PIP_UPLOADED_PRIOR_TO` unconditionally; older pip releases silently ignore it and the gate becomes a no-op, which is the worst failure mode for a security control. Probing `python -m pip --version` and refusing injection below `26.1` (or bumping the manager's `requirement` outright) closes the false-security window.
122+
- **Route stale pip through `uv pip`.** For users stuck on pip `<26.1`, `mpm` could borrow `uv`'s resolver (`uv pip install --exclude-newer`) when `uv` is present, giving sound transitive-correct enforcement without reimplementing one.
123+
- **Onboard mechanisms as they ship upstream.** Several managers have active work that would slot into the [`cooldown_env_var`](#how-it-works) framework as a one-line addition once released: Composer ([#12692](https://github.com/composer/composer/pull/12692)), Bundler / RubyGems ([#9576](https://github.com/ruby/rubygems/pull/9576)), Cargo (stabilization of `-Zmin-publish-age`, [#17009](https://github.com/rust-lang/cargo/issues/17009)), dnf5 ([#2743](https://github.com/rpm-software-management/dnf5/issues/2743)), Scoop ([#6513](https://github.com/ScoopInstaller/Scoop/issues/6513)), winget ([#6178](https://github.com/microsoft/winget-cli/issues/6178)), VS Code ([#316867](https://github.com/microsoft/vscode/issues/316867)).
124+
- **Advisory mode for `outdated` on unsupported managers.** `mpm` could query each package registry directly (PyPI, RubyGems, crates.io, ...) to annotate `outdated` with a "safe latest" column: purely informational, no install-side enforcement. This avoids the transitive-resolution trap while still being useful. It requires a new HTTP client surface and a state directory for date caching, neither of which `mpm` has today.
125+
- **Block-mode for bundled-artifact managers** (`snap`, `flatpak`, `vscode`, `mas`). These install self-contained artifacts with no separate transitive resolution at install time, so a "refuse if fresher than the cutoff" check would be sound without a resolver. The bottleneck is per-store API support for per-version publish dates.
126+
127+
## Prior art
128+
129+
- [uv `exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) — the model for the Python ecosystem.
130+
- [npm `before`](https://docs.npmjs.com/cli/v11/using-npm/config#before) and its newer companion `min-release-age` (shipped in `npm` 11.10).
131+
- [Renovate `minimumReleaseAge`](https://docs.renovatebot.com/configuration-options/#minimumreleaseage) — delays dependency PRs by a configurable period.
132+
- William Woodruff, [*We should all be using dependency cooldowns*](https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns).

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ install
1212
usecase
1313
cli-parameters
1414
configuration
15+
cooldown
1516
bar-plugin
1617
falsehoods
1718
benchmark

docs/usecase.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -458,21 +458,6 @@ requirements. But Docker might be too big for some people.
458458
`mpm` can be a lightweight alternative to Docker, to abstract the runtime
459459
from their execution environment.
460460

461-
### Update cooldowns
462-
463-
```{todo}
464-
`mpm` could support a cooldown period before applying updates, letting newly released versions age for a configurable delay (e.g. 7 days) before they are offered by `mpm upgrade` or `mpm outdated`.
465-
466-
This would mitigate supply chain attacks by giving the community time to identify and yank compromised releases before they land on your machine. The [xz backdoor](https://en.wikipedia.org/wiki/XZ_Utils_backdoor) and [VS Code extension marketplace incidents](https://www.wiz.io/blog/supply-chain-risk-in-vscode-extension-marketplaces) are recent examples of this threat.
467-
468-
Some package managers have no built-in delay mechanism at all (e.g. VS Code extensions only offer auto-update on/off, with no `update.delayDays` option — see [microsoft/vscode#24823](https://github.com/microsoft/vscode/issues/24823)). `mpm` is in a unique position to add a uniform cooldown layer across all managers it wraps.
469-
470-
Prior art:
471-
- Renovate's [`minimumReleaseAge`](https://docs.renovatebot.com/configuration-options/#minimumreleaseage) delays dependency PRs by a configurable period.
472-
- uv's [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) ignores packages published after a cutoff date.
473-
- William Woodruff's [*We should all be using dependency cooldowns*](https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns).
474-
```
475-
476461
### Support and fund open-source?
477462

478463
```{todo}

meta_package_manager/managers/pip.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,27 @@ class Pip(PackageManager):
5555

5656
platforms = ALL_PLATFORMS
5757

58-
requirement = ">=10.0.0"
58+
requirement = ">=26.1.0"
59+
"""`26.1 <https://github.com/pypa/pip/releases/tag/26.1>`_ is the first version to
60+
ship ``--uploaded-prior-to``, the release-age gate mpm uses for the supply-chain
61+
cooldown (see :py:attr:`cooldown_env_var`). Older pip releases silently ignore
62+
``PIP_UPLOADED_PRIOR_TO``, so the floor avoids advertising a gate that does
63+
nothing.
64+
"""
65+
66+
cooldown_env_var = "PIP_UPLOADED_PRIOR_TO"
67+
"""pip honors a release-age cooldown through its ``--uploaded-prior-to`` resolver
68+
option.
69+
70+
pip maps any ``PIP_<UPPER_SNAKE>`` environment variable to a config setting, so
71+
``PIP_UPLOADED_PRIOR_TO`` sets the option without touching the user's ``pip.conf``.
72+
The flag excludes from resolution any distribution uploaded after the given
73+
instant, which covers ``install`` and ``upgrade`` (with transitive dependencies).
74+
pip parses the RFC 3339 timestamp produced by the default
75+
:py:meth:`cooldown_env_value`.
76+
77+
See https://github.com/pypa/pip/issues/13674.
78+
"""
5979

6080
_SEARCH_REGEXP = re.compile(
6181
r"""

meta_package_manager/managers/pipx.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ class Pipx(PackageManager):
4444
1.0.0
4545
"""
4646

47+
cooldown_env_var = "PIP_UPLOADED_PRIOR_TO"
48+
"""pipx defers resolution to pip, so it honors pip's ``--uploaded-prior-to``
49+
gate through the same environment variable.
50+
51+
Setting ``PIP_UPLOADED_PRIOR_TO`` on a pipx invocation propagates to the pip
52+
subprocess pipx spawns to install the application and its dependencies, so the
53+
cutoff applies to the whole resolution. mpm injects the RFC 3339 timestamp from
54+
the default :py:meth:`cooldown_env_value`.
55+
56+
.. caution::
57+
Same caveat as :py:class:`meta_package_manager.managers.pip.Pip`: the
58+
underlying pip must be at least ``26.1`` for the gate to take effect. Older
59+
pip releases silently ignore the env var.
60+
61+
See https://github.com/pypa/pipx/issues/1811.
62+
"""
63+
4764
@property
4865
def installed(self) -> Iterator[Package]:
4966
"""Fetch installed packages.

0 commit comments

Comments
 (0)