Python
SlowShield serves PyPI at https://slowshield.example.com/pypi/simple/, a standard package
index (PEP 503 and PEP 691). Every tool that takes an index URL works with it: pip, uv, Poetry, PDM, Pipenv, and what
installs through them, such as uvx, pipx and pre-commit hooks.
Set it up
pip and uv read environment variables, so a few lines in your shell profile cover them in every new terminal (the Setup page has bash on macOS, zsh and fish too):
cat >> ~/.bashrc <<'EOF'
export PIP_INDEX_URL=https://slowshield.example.com/pypi/simple/
export UV_DEFAULT_INDEX=https://slowshield.example.com/pypi/simple/
export npm_config_registry=https://slowshield.example.com/npm/
export GOPROXY=https://slowshield.example.com/go
export PIP_UPLOADED_PRIOR_TO=P3D
export npm_config_min_release_age=3
EOF
source ~/.bashrcRemove the PIP_UPLOADED_PRIOR_TO and npm lines if you don't want the second layer or don't use npm.
Each tool, with the version its own release age needs:
pip
Release age: pip 26.1 or later (pip 26.0 fails with it, 25 and older ignore it). Installs from pylock.toml fail with it, because pip doesn't record upload times there
command
pip config set global.index-url https://slowshield.example.com/pypi/simple/release age
pip config set global.uploaded-prior-to P3Duv
Release age: uv 0.9.17 or later (older versions fail with it). In pyproject.toml, not the environment: uv records it in uv.lock, so a different value elsewhere breaks uv sync --locked
pyproject.toml
[[tool.uv.index]]
name = "slowshield"
url = "https://slowshield.example.com/pypi/simple/"
default = true
[tool.uv]
exclude-newer = "P3D"environment
export UV_DEFAULT_INDEX=https://slowshield.example.com/pypi/simple/Poetry
Release age: Poetry 2.4 or later
command
poetry source add --priority=primary slowshield https://slowshield.example.com/pypi/simple/release age
poetry config solver.min-release-age 3PDM
Release age: PDM 2.27 or later. In pyproject.toml, not as pdm lock --exclude-newer, which isn't kept and re-resolves every pin
pyproject.toml
[[tool.pdm.source]]
name = "pypi"
url = "https://slowshield.example.com/pypi/simple/"
[tool.pdm.resolution]
exclude-newer = "3d"Pipenv
Pipfile
[[source]]
url = "https://slowshield.example.com/pypi/simple/"
verify_ssl = true
name = "slowshield"What changes
- Files wait, not just versions. Each wheel and sdist becomes installable 7 days after its own
upload-time(PEP 700), which PyPI sets and authors can't. A wheel added to an old release waits too. A release none of whose files is old enough is left out of the index, so resolvers pick the newest release that has one. - A pin that is too new. Resolvers don't see held files, so
pip install pkg==1.2.3reports that no matching distribution exists. A lockfile that records file URLs, such asuv.lock, downloads the file directly and gets403withRetry-Afterand the time it becomes available. - Malware from the OSV and GitHub feeds is refused with
451and the advisory, also when it is older than the delay. - Brand-new packages none of whose files is old enough are served and recorded as fail-open
(
X-SlowShield-Fail-Open: 1), unless your administrator setfail_open = false. That's what the second layer is for. - Lockfiles.
uv.lockandPipfile.lockrecord the index URL; lock with SlowShield in place so they name it. Hashes don't change: SlowShield serves PyPI's files byte for byte.
The second layer
pip 26.1, uv 0.9.17, Poetry 2.4 and PDM 2.27 can refuse releases younger than a few days themselves. Set them to 3 days, below SlowShield's 7, and they stay silent on a normal day. Notes from testing them on 2026-10-06:
- uv: put
exclude-newerinpyproject.toml, not the environment. uv records it inuv.lock, so a different value elsewhere (CI, a Dockerfile) breaksuv sync --locked. For one package you need early:exclude-newer-package. - pip:
PIP_UPLOADED_PRIOR_TO=P3Dneeds pip 26.1; pip 26.0 refuses to run with it and older versions ignore it. Installs frompylock.tomlfail with it, because pip doesn't record upload times there. - PDM: in
[tool.pdm.resolution];pdm lock --exclude-newerisn't kept and re-resolves every pin.
In Docker and CI
A build container doesn't read your shell profile. Pass the index as a build argument: an ARG is
visible to RUN as an environment variable and isn't kept in the image.
FROM python:3.13-slim
ARG PIP_INDEX_URL=https://slowshield.example.com/pypi/simple/
# uv: ARG UV_DEFAULT_INDEX=https://slowshield.example.com/pypi/simple/
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txtWith a default in the Dockerfile, every build uses SlowShield and --build-arg PIP_INDEX_URL=… can still
override it. Building images covers the base image too, and how to check that
nothing in a build goes around SlowShield. In CI, set the variables for the job:
# GitHub Actions (workflow or job)
env:
PIP_INDEX_URL: https://slowshield.example.com/pypi/simple/
UV_DEFAULT_INDEX: https://slowshield.example.com/pypi/simple/
npm_config_registry: https://slowshield.example.com/npm/
GOPROXY: https://slowshield.example.com/go
PIP_UPLOADED_PRIOR_TO: P3D
npm_config_min_release_age: 3Limits
- Requirements from git repositories or direct URLs (
pkg @ https://…) don't go through an index, so SlowShield doesn't see them. --extra-index-urlpointing at pypi.org goes around SlowShield: pip picks the best version across all indexes. Use private indexes for private packages only, and block pypi.org and files.pythonhosted.org at the firewall.- Plain HTTP (a local test instance) needs
PIP_TRUSTED_HOSTfor pip.
For agents: this page as Markdown · llms.txt · the SlowShield skill