uv-supply-chain-hardening
Skill1 starsChanged 10 days agoMITView on GitHub
Harden a Python project's dependency supply chain by switching Docker builds from pip to uv, locking dependencies with hashes against a verified runtime baseline, and adding a release-age gate so freshly-uploaded (possibly compromised) packages can't be pulled in. Use when locking down dependencies, defending against supply-chain attacks, or migrating a Dockerized Python build from pip to uv.
- Reads credentials
- Deletes or force-pushes
- Installs packages
In this repository14 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- aegis-nextjs-frontendSkill
- byteforge-loki-loggingSkill
- byteforge-prometheus-metricsSkill
- flask-docker-deploymentSkill
- flask-smorest-apiSkill
- flask-telegram-botSkill
- gatekeeper-nginx-setupSkill
- mcp-async-upgradeSkill
- mcp-docker-deploymentSkill
- mcp-server-nginx-gatekeeperSkill
- postgres-setupSkill
- python-lib-setupSkill
- python-project-scaffoldSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
29 rules. About 0 are common, 16 are distinctive, and 13 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈16
Distinctive · estimate
This author's own choices. Worth a look.
13
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Distinctive: line 75
Check that inventory commands succeeded and produced the expected nonempty package lists before using them.
- Very specific: line 78
If pip is absent, use the environment's existing uv pip freeze --python {RUNTIME_PYTHON} or obtain an equivalent distribution/version and …
- Distinctive: line 99
If there is no deployment, use the tested environment the user identifies and record that choice.
- Distinctive: line 109
Default: 7 days. Long enough that most malicious releases get caught and yanked.
- Very specific: line 113
If yes, note the token env var name (commonly CR_PAT for a GitHub PAT).
- Distinctive: line 117
Treat these as explicit changes to the runtime baseline.
- Very specific: line 341
If the project already has uv.toml, put the age gate there as a top-level key instead: uv reads it in preference to [tool.uv] in the same …
- Distinctive: line 359
Prefer the duration: it's a rolling gate that keeps working on every future build, whereas a fixed timestamp goes stale.
- Very specific: line 386
CRITICAL: use the backend the project already uses, pinned to its installed version (pip show hatchling / pip show setuptools).
- Distinctive: line 401
Use the same helper inside the build and for the pre-push checks in Step 6.
- Distinctive: line 433
Keep all private URLs token-free, including transitive declarations.
- Very specific: line 442
Remove token ARG/ENV declarations and --build-arg use from build scripts and CI.
- Very specific: line 445
Add .env, .env.*, token files and verification output directories to .dockerignore.
- Distinctive: line 471
PyPI deps: all carry hashes in the lock, so they're verified regardless.
- Very specific: line 484
If the user does want it, split.
- Distinctive: line 495
Use this only if the user chose "always track latest" for their own libs in Step 0.
- Very specific: line 538
Use the Step 1 with_private_git helper below.
- Very specific: line 629
If you want provenance back, replace the grep -vE with a range-aware awk that drops each matched line's trailing indented # via block too.
- Very specific: line 674
Keep configuration discovery enabled.
- Distinctive: line 680
If a registry build dependency is too new for the selected gate, retain the gate and use a compatible eligible version or wait for it to …
- Very specific: line 745
Before the local install, check where the project's venv lives.
- Distinctive: line 761
Check git check-ignore -v -- .lock and review git status --short before committing.
- Distinctive: line 847
If older tokens are available, scan for their values through separate secret inputs.
- Distinctive: line 850
After an authorized push, verify the registry digest and pull/scan that immutable reference with the same commands (no need to delete local …
- Very specific: line 863
Compile without constraints jumps to latest: Always pass -c <pip-freeze-output> on the first compile, or you'll lock to versions you never …
- Distinctive: line 916
If A's own metadata insists on the too-new B, choose a compatible older A as well or wait for B to age in.
- Distinctive: line 966
If you find a bug or stale instruction while using this skill:
- Very specific: line 968
If HiveMake is connected and byteforge-skills-maintainer-agent is available, file a ticket with that agent.
- Distinctive: line 972
If you cannot submit a report, give the human the details and this link.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
uv Supply-Chain Hardening
This skill converts a Python project from a loose pip install -r requirements.txt
build into a locked, hash-verified, release-age-gated build using
uv. It is the response to the wave of
supply-chain attacks targeting Python (and especially crypto) projects, where a
maintainer account is compromised and a malicious version is published to PyPI.
The defense has three layers:
- Pin every dependency to an exact version — no floating ranges, so a build can't silently pull a newer (compromised) release.
- Verify hashes — every PyPI distribution is checked against a SHA256 in the lockfile, so a tampered-with artifact on the registry is rejected.
- Release-age gate — refuse any distribution published in the last N days, so a freshly-uploaded malicious version isn't pulled in before the community has a chance to catch and yank it.
When to Use This Skill
- You have a Python project (with
requirements.txtand/orpyproject.toml) whose Docker build runspip installagainst unpinned or loosely-pinned dependencies. - You want reproducible, tamper-evident builds.
- You're worried about a dependency (or one of its transitive deps) being hijacked.
- You have private Git dependencies and need them to coexist with hash-locking.
What This Skill Changes
requirements.in(NEW) — human-edited source-of-truth list of direct deps.requirements.txt(REWRITTEN) — machine-generated, fully-pinned, hashed lock.pyproject.toml— adds[tool.uv] exclude-newerand pins the build backend (or a rootuv.tomlif the repo has nopyproject.toml).Dockerfile— installs via a digest-pinneduvinstead ofpip; private-dep token is passed through a BuildKit secret, with value-based artifact checks.requirements-private.txt(NEW, optional) — first-party libs installed at HEAD when the user wants their own libraries to always track latest (Step 5b).dev-requirements.in/dev-requirements.txt(NEW, optional) — same pinned+hashed+age-gated treatment for dev tooling likepytest/black/mypy(Step 5c).dev_scripts/assert_no_secret.py(NEW, for private-dependency builds) — value-based checks before publishing, including saved image layers.
Nothing about the application code changes — this is purely the dependency pipeline.
Step 0: Establish the Baseline
Hardening preserves an agreed runtime baseline; it does not authorize version changes. A development venv can be older than production. Freezing it blindly can silently downgrade the deployed application even when the compile preserves every local pin. Unconstrained resolution can instead introduce untested upgrades.
For an existing deployment, capture inventories from both the development venv and the currently running production image, using the interpreter that runs the application (including its venv path). Record the production image ID/digest, Python version, OS/libc, and architecture with the inventory. A mutable image tag or a newly rebuilt image is not evidence of what production currently runs.
# Inside the project's activated virtualenv
uv --version || pip install uv # or: brew install uv / pipx install uv
python -m pip freeze > /tmp/development-inventory.txt
# Read-only inventory on the deployment host; replace both placeholders.
docker inspect --format '{{.Image}}' {PRODUCTION_CONTAINER}
docker exec {PRODUCTION_CONTAINER} {RUNTIME_PYTHON} -m pip freeze > /tmp/production-inventory.txtCheck that inventory commands succeeded and produced the expected nonempty package lists before using them; a failed capture must not become empty constraints.
If pip is absent, use the environment's existing uv pip freeze --python
{RUNTIME_PYTHON} or obtain an equivalent distribution/version and VCS-source
inventory from the deployment owner. Do not install tooling into production just
to collect it. Keep inventories local and protect/redact credentials in VCS URLs.
Compare development and production inventories by normalized distribution name
(lowercase, treating runs of -, _, and . identically). Default the runtime
constraints to production, not the laptop. Before compiling, identify every
proposed runtime addition, removal, or version/source change. Ask the user about
any downgrade or correctness-sensitive package change unless that exact change
is already authorized. Include the production and proposed versions and the
reason; a blanket request to harden dependencies is not approval to change them.
For example, production ta-lib==0.8.1 versus development ta-lib==0.6.8 must not
silently become a 0.6.8 runtime lock.
Once the baseline is agreed, copy its inventory to
/tmp/installed-constraints.txt (normally /tmp/production-inventory.txt). Apply
only explicitly approved version changes to this working copy; keep the original
production inventory unchanged for the post-compile comparison. Use the dev
inventory separately for dev-only tooling, never to override runtime pins.
If there is no deployment, use the tested environment the user identifies and record that choice. If production exists but its inventory is unavailable, ask for it or explicit approval of an alternative baseline; do not claim a local freeze preserves production. For multiple deployed platforms/versions, inventory each target and resolve differences explicitly rather than merging incompatible pins into one baseline.
Ask the user:
- "How many days should the release-age gate be?"
- Default: 7 days. Long enough that most malicious releases get caught and yanked; short enough you still get timely security patches.
- "Do you have private Git dependencies?" (yes/no)
- If yes, note the token env var name (commonly
CR_PATfor a GitHub PAT).
- If yes, note the token env var name (commonly
- "Before we freeze, are there any of your own libraries you want to update
first?"
- Treat these as explicit changes to the runtime baseline. Update and test them in an isolated candidate environment, then recapture that inventory; keep the original production snapshot for comparison.
- "Should your own private libraries be pinned, or always track latest?"
- A common, legitimate stance: pin the third-party attack surface, but let
your first-party libs (the ones you maintain) float to the latest
commit on every build — you review your own code and want fixes without a
manual SHA bump. If they choose this, those libs do not get pinned in the
lock; they go in a separate
requirements-private.txtinstalled at HEAD. See Step 5b — it changes Steps 1, 2, and 4.
- A common, legitimate stance: pin the third-party attack surface, but let
your first-party libs (the ones you maintain) float to the latest
commit on every build — you review your own code and want fixes without a
manual SHA bump. If they choose this, those libs do not get pinned in the
lock; they go in a separate
- "What Python version, OS/libc, and architecture does the container run?"
- You must compile the lock for the container's Python (
--python-version), not your laptop's. A local 3.14 venv resolving for a 3.13 image picks different wheels/markers. Read it off theFROM python:X.Yline. Also pass the target--python-platformon every compile: e.g.x86_64-unknown-linux-muslfor x86_64 Alpine,x86_64-unknown-linux-gnufor x86_64 Debian, or the matching aarch64 target for ARM. Do not infer the container platform from the laptop.
- You must compile the lock for the container's Python (
Step 1: Create requirements.in (source of truth)
requirements.in lists only your direct dependencies — the things you actually
import — with no version pins (the lock supplies those). This is the file a human
edits; requirements.txt is never hand-edited again.
# requirements.in — direct dependencies only. Edit this, then recompile
# requirements.txt with:
# uv pip compile requirements.in --generate-hashes --python-version 3.13 \
# --python-platform x86_64-unknown-linux-musl -o requirements.txt
#
# Pins and hashes for the full transitive tree live in requirements.txt.
example-http-client
example-db-driver
some-public-lib
# Private Git dependency — pinned to an immutable commit SHA, not a branch.
# The commit SHA is the integrity anchor (a branch can be force-pushed; a SHA
# can't). Keep URLs token-free; process-scoped Git configuration supplies auth.
internal-lib @ git+https://github.com/your-org/internal-lib.git@<commit-sha>CRITICAL replacements:
- Direct dependency names → the user's actual top-level imports (mine these from the
existing
pyproject.tomldependenciesand/or the un-hashedrequirements.txt). internal-lib @ git+...@<commit-sha>→ each private dep, pinned by full commit SHA. If it's currently pinned to a branch or unpinned, resolve the current commit (git ls-remote https://github.com/your-org/internal-lib.git <branch>) and pin it.CR_PATin the authentication examples → the user's token env var name.
For local private-Git operations, define this Bash helper in the working shell
after loading the token securely. Use with_private_git uv ... for each compile
or install that fetches private repos, and with_private_git git ls-remote ...
when resolving a pin. It scopes credentials to a subshell and writes no gitconfig:
with_private_git() (
set +x
: "${CR_PAT:?Load the private Git token first}"
export GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="url.https://${CR_PAT}@github.com/.insteadOf" \
GIT_CONFIG_VALUE_0="https://github.com/"
"$@"
)Replace the host/rewrite for the project's Git provider. If the environment already
uses GIT_CONFIG_COUNT, append to its entries instead of overwriting them. Do not
enable shell tracing or dump the environment/config while credentials are loaded.
Why a commit SHA for Git deps: Git distributions cannot carry a PyPI-style hash in the lock (see Step 5). The commit SHA is the hash — it's the only thing anchoring the integrity of a Git dependency, so it is non-negotiable for these.
⚠️ Token-baking via a library's OWN
pyproject.toml(the subtle leak — check this every time). Distinct from the DockerfileENVleak in Step 4/6. If one of your private libraries declares its own dependencies asgit+https://{env:CR_PAT}@github.com/...(the hatch{env:...}context form), hatchling expands the token at build time into the wheel'sRequires-Distmetadata — so a live PAT gets frozen into every image built from that lib, even with a flawless Dockerfile. Use token-free URLs in requirements, locks, and package metadata alike; keep authentication in Git's process configuration. Fix the library: declare its deps with token-free URLs (git+https://github.com/...) and let gitinsteadOfsupply auth at install — this stays compatible with unpinned/always-latest deps. Then rotate the PAT, since older built images still carry it. Scan for it in Step 6.
Step 2: Compile the Locked, Hashed requirements.txt
Compile from requirements.in, constrained to the agreed runtime baseline, with
hashes. Write a candidate first so review precedes replacement of the runtime lock:
Apply the Step 3 release-age configuration before this first compile so its resolution is already gated. For private Git dependencies use the Step 1 auth helper around the compile command.
# A raw `pip freeze` includes VCS/editable lines (`pkg @ git+...`, `-e ...`,
# `pkg @ file://...`) that uv rejects as constraints — keep only `name==version`:
grep -E '^[A-Za-z0-9._-]+==[0-9]' /tmp/installed-constraints.txt > /tmp/constraints.txt
uv pip compile requirements.in \
--generate-hashes \
--python-version 3.13 --python-platform x86_64-unknown-linux-musl \
-c /tmp/constraints.txt \
-o requirements.candidate.txt--generate-hashes→ writes a SHA256 (often several, one per wheel/sdist) for every PyPI distribution. This is what makes registry tampering detectable.--python-version <X.Y>→ resolve for the Python the container runs, not your laptop's. Compiling under a different interpreter (local 3.14, image 3.13) can select different wheels/markers. Match theFROM python:X.Yin the Dockerfile.--python-platform→ the container OS/libc and architecture from Step 0; all examples below use x86_64 Alpine. Replace this consistently for the actual target.-c /tmp/constraints.txt→ pins to the agreed runtime baseline from Step 0 rather than resolving to latest. This is the step people forget; without it the lock can leap forward across untested releases.- The output is the full transitive tree, every package pinned to
==with hashes.
Review before replacing the lock. Compare the candidate registry pins with both the agreed constraints and the untouched production inventory. Normalize distribution names and check reads before diffing; process-substitution pipelines can hide read failures as empty output. Run these comparisons separately from the promotion command and inspect both diffs:
registry_pins() {
python3 -c '
from pathlib import Path
import re
import sys
pins = []
text = Path(sys.argv[1]).read_text()
if not text.strip():
raise SystemExit("Empty inventory or candidate lock")
for line in text.splitlines():
match = re.match(r"^([A-Za-z0-9_.-]+)==([0-9][^\s;]*)", line)
if match:
name = re.sub(r"[-_.]+", "-", match[1]).lower()
pins.append(name + "==" + match[2])
print("\n".join(sorted(pins)))
' "$@"
}
# Candidate must match the agreed baseline.
registry_pins /tmp/installed-constraints.txt > /tmp/baseline-pins.txt || exit 1
registry_pins requirements.candidate.txt > /tmp/candidate-pins.txt || exit 1
comparison_status=0
diff -u /tmp/baseline-pins.txt /tmp/candidate-pins.txt || comparison_status=1
# For existing deployments, also expose changes relative to actual production.
registry_pins /tmp/production-inventory.txt > /tmp/production-pins.txt || exit 1
diff -u /tmp/production-pins.txt /tmp/candidate-pins.txt || comparison_status=1
test "$comparison_status" -eq 0diff returns 1 when versions or package membership differ; that is a review
finding, not a reason to skip the comparison. Stop on read/command errors. Skip
only the production comparison when Step 0 established that no production
inventory is available and an alternative baseline was authorized.
Dev-only tooling in a selected non-production baseline (pytest / black /
mypy / ruff / etc.) can be absent from the runtime lock. Confirm it is dev-only
before excluding it from the comparison. If it should also be hardened, see
Step 5c.
Review every runtime addition, removal, and version change, including transitive packages. The registry diff omits VCS/editable/local-source entries: separately verify their intended source and commit, including any authorized Step 5b HEAD tracking. Stop for any unapproved difference; a successful resolver run is not approval. If a baseline pin cannot satisfy the target platform or age gate, keep the existing lock and agree a compatible change or wait; do not silently loosen constraints. Promote only after this review:
mv requirements.candidate.txt requirements.txtReproducible header — one extra compile pass. The initial compile records
-c /tmp/constraints.txtinrequirements.txt's autogenerated header (and in every# via -c ...annotation), so a reader can't rerun the recorded command from the repo — the file it points at doesn't exist. Fix it in one pass by re-compiling with no-cat all; uv reads pins from the existing-o requirements.txt(see the "preserves pins" property below), so resolution is expected to preserve versions. Recheck the resulting version/source diff against the reviewed candidate afterward; if it moved, restore the reviewed lock and investigate before proceeding:cp requirements.txt /tmp/reviewed-runtime-lock.txt uv pip compile requirements.in \ --generate-hashes --python-version 3.13 --python-platform x86_64-unknown-linux-musl \ -o requirements.txtThe header and annotations now reference only
requirements.inandrequirements.txt— a fresh clone can rerun the recorded command as-is. Do this only on the initial compile (a routine recompile is already this shape).
uv preserves existing pins on recompile — in the single-lock flow. Once
requirements.txtexists and is the-otarget, a lateruv pip compilereads pins from it and keeps them unless you pass--upgrade(or--upgrade-package NAME). Routine recompiles (e.g. after adding one new dep torequirements.in) won't silently bump everything else. Document this in the file header so the next editor knows the lock is sticky.This does NOT hold in the Step 5b split flow — that variant compiles to a throwaway intermediate (
requirements.full.txt) that getsrm'd, so the next recompile has nothing to preserve against. Step 5b handles that with an explicit-c requirements.txton subsequent runs; see there.
Step 3: Add the Release-Age Gate + Pin the Build Backend (pyproject.toml)
Two additions to pyproject.toml.
If the project already has uv.toml, put the age gate there as a top-level key
instead: uv reads it in preference to [tool.uv] in the same directory's
pyproject.toml. Preserve existing settings. See uv's
configuration precedence.
(a) The release-age gate under [tool.uv]:
[tool.uv]
# Supply-chain defense: refuse any distribution published in the last 7 days when
# resolving/locking, so a freshly-uploaded (possibly compromised) version can't be
# pulled in before the community catches and yanks it. Applies to BOTH
# `uv pip compile` and `uv pip install`. This is a rolling window evaluated at
# run time — not a fixed date — so it keeps protecting future installs.
exclude-newer = "7 days"exclude-neweraccepts a friendly duration ("7 days") or an ISO-8601 timestamp. Prefer the duration: it's a rolling gate that keeps working on every future build, whereas a fixed timestamp goes stale.- Replace
7 dayswith the answer from Step 0.
No
pyproject.toml? (a Flask/service repo, not a package.) Put the setting in auv.tomlat the repo root instead — uv reads it for both compile and install. Inuv.tomlthe keys are top-level (no[tool.uv]table):# uv.toml exclude-newer = "7 days"And skip part (b) below — a repo that never builds a wheel has no build backend to pin.
(b) Pin the build backend under [build-system] — otherwise the backend itself
(e.g. hatchling/setuptools) is an unpinned dependency resolved at wheel-build time,
and is just as hijackable as any other:
[build-system]
# Pinned (not floating) so the build backend can't be swapped for a newer,
# potentially compromised release at build time. exclude-newer keeps it >=7 days
# old; bump deliberately when upgrading.
requires = ["hatchling==1.29.0"]
build-backend = "hatchling.build"CRITICAL: use the backend the project already uses, pinned to its installed
version (pip show hatchling / pip show setuptools). Don't switch backends.
Step 4: Convert the Dockerfile from pip to uv
Replace the install flow while preserving the project's base image, startup and
non-root user conventions. Private dependencies require a BuildKit secret mount;
ARG and ENV are not safe secret transports. Docker documents this distinction
in Build secrets.
Copy this skill's scripts/assert_no_secret.py to
dev_scripts/assert_no_secret.py in the target project. The stdlib-only helper
checks literal token values without printing matches and fails on scan errors.
Use the same helper inside the build and for the pre-push checks in Step 6.
# syntax=docker/dockerfile:1
FROM python:3.13-alpine
COPY --from=ghcr.io/astral-sh/uv:0.9.28@sha256:<digest> /uv /uvx /bin/
RUN apk add --no-cache git gcc musl-dev postgresql-dev
WORKDIR /app
# Copy the configuration actually used in Step 3; add uv.toml if it takes precedence.
COPY requirements.txt pyproject.toml README.md ./
COPY src/ ./src/
COPY dev_scripts/assert_no_secret.py /usr/local/bin/assert_no_secret.py
RUN --mount=type=secret,id=cr_pat,required=true \
set -eu; set +x; \
CR_PAT="$(cat /run/secrets/cr_pat)"; test -n "$CR_PAT"; \
export GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="url.https://${CR_PAT}@github.com/.insteadOf" \
GIT_CONFIG_VALUE_0="https://github.com/"; \
uv pip install --system --no-cache -r requirements.txt; \
uv pip install --system --no-cache --no-deps .; \
uv pip check --system; \
python /usr/local/bin/assert_no_secret.py --secret-file /run/secrets/cr_pat \
/usr/local/lib /app /etc /root /tmp
# ... non-root user, ENV, EXPOSE, ENTRYPOINT as before ...- Substitute the uv tag/digest, Python/platform, Git host, package paths and build
packages. For a service that is not a Python package, omit the local
.install and copy its application sources using its established layout. - Keep all private URLs token-free, including transitive declarations. Authentication
uses Git's process configuration,
not
git config --global. Do not persist the rewrite in any file.--no-cachehere is uv's cache policy: it avoids retaining fetched source/build artifacts. - The secret mount protects the input, not what build tools write or log. Keep tracing off; retain the scan after every secret-bearing install RUN, in that same RUN. Include other install prefixes/cache directories if the project uses them. A failed scan stops the build; fix the package/source that writes secrets instead of deleting metadata or blindly replacing bytes in installed packages.
- Remove token
ARG/ENVdeclarations and--build-arguse from build scripts and CI. Pass--secret id=cr_pat,env=CR_PATinstead. Require BuildKit; if unavailable, configure a builder with secret support rather than falling back to build args. - Add
.env,.env.*, token files and verification output directories to.dockerignore; avoid copying credentials with application sources. - For public-only builds with no token to verify, omit the secret mount, Git auth setup and secret scanner invocation; keep uv's lock/config, dependency checks and smoke tests. Omit Git only if no VCS deps need it.
Resolve <digest> for the selected uv tag without needing Docker daemon access:
docker buildx imagetools inspect ghcr.io/astral-sh/uv:0.9.28
# Use the top-level Digest (manifest/index), which supports the selected platforms.This is a registry inspection,
not a local pull. If buildx is unavailable, crane digest against the same reference
or an already-reviewed matching tag/digest from a maintained sibling Dockerfile is
an alternative. Verify the selected image supports the target platform. Preserve
the existing digest when no uv upgrade is intended.
Step 5: The --require-hashes / Git-dependency Tradeoff
You may consider adding --require-hashes to the install for maximum strictness. Be
aware of the catch and decide consciously:
- PyPI deps all carry hashes in the lock, so they're verified regardless.
- Git dependencies cannot be hashed — there's no immutable artifact hash for a
git+https://...source, only the commit SHA (which you already pinned in Step 1). --require-hashesis all-or-nothing: it rejects the entire requirements file if any single line lacks a hash. So you can't use it unless you split the install into two steps — hashed PyPI deps in one file, unhashable Git deps in another.
Recommendation: unless the user wants the split, omit --require-hashes. uv
still verifies every hash that is present (all the PyPI deps), so registry tampering
is already caught; --require-hashes would only additionally block a future
unhashed line from sneaking in. Note this as an accepted tradeoff rather than
silently skipping it.
If the user does want it, split. Run the Git install inside the Step 4 secret/auth/scan block if any of those repos are private; these are the install commands only:
uv pip install --system --no-cache --require-hashes -r /app/requirements-pypi.txt
uv pip install --system --no-cache -r /app/requirements-git.txtStep 5b: First-Party Libraries That Should Track Latest (the --override split)
Use this only if the user chose "always track latest" for their own libs in Step 0. The default model pins everything; this variant pins the third-party attack surface but lets first-party libs float to the latest commit on every build.
Why a plain compile won't do it — two uv behaviors collide:
- uv resolves the entire graph; pip deduped by name. pip tolerated your
private libs declaring each other with
{env:CR_PAT}/unpinned URLs because the top-level requirement already "satisfied" them. uv actually fetches each transitive git URL from a library's metadata — and if it uses{env:CR_PAT}(uv can't expand it) or floats toHEAD(conflicting with a top-level pin), the compile fails with an auth or URL-conflict error. - uv pins a git dep to its resolved HEAD commit in the output even when the input had no SHA — so a single lock would freeze your first-party libs to compile-time HEAD, defeating "always latest."
The split that solves both:
requirements-private.txt — all direct and transitive first-party libs,
token-free and unpinned. Used
twice: as the --override for the compile and as the install list in Docker.
internal-core @ git+https://github.com/your-org/internal-core.git
internal-models @ git+https://github.com/your-org/internal-models.gitThe list must include the entire first-party dependency graph: if internal-core
depends on internal-models, both need entries even if only core is a direct app
dependency. Inspect the baseline's VCS entries (pip freeze | grep 'git+', locally
with credentials redacted), package dependency metadata, and the full compile
output. A baseline alone can miss newly added transitive libraries. Classify by
ownership, not by whether a URL needs authentication. Every first-party package
removed from the lock must appear in this list; every retained Git package must
keep its intended pin. Replace the removal regex's example names in every compile
below with that complete first-party list, using uv's normalized distribution names.
Confirm with uv pip check and import smoke tests.
requirements.in — third-party direct deps plus the private libs (token-free),
so the compile discovers and locks their third-party sub-tree. The private lines are
stripped from the output afterward.
Compile (token injected only into git's process config, never a committed file).
Use the Step 1 with_private_git helper below; if all Git URLs are public, invoke
uv directly. Choose the compile mode that matches what you're doing:
Initial compile (first run, no requirements.txt yet):
with_private_git uv pip compile requirements.in \
--override requirements-private.txt \
--generate-hashes --no-annotate --python-version 3.13 \
--python-platform x86_64-unknown-linux-musl \
-c /tmp/constraints.txt \
-o requirements.full.txt
# Strip the first-party git lines → the candidate holds only the locked, hashed
# third-party tree (their PyPI sub-deps stay; the private libs themselves do not).
grep -vE '^(internal-core|internal-models) @ git\+' requirements.full.txt > requirements.candidate.txt
rm requirements.full.txtApply Step 2's baseline/production diff and approval gate to this candidate, then
promote it to requirements.txt. Validate the resolved first-party commits against
the chosen pinned/HEAD policy separately; they are intentionally absent from the
registry lock. The split flow retains its -c on subsequent recompiles; do not
apply Step 2's unconstrained header-cleanup pass to it.
Routine recompile (later — adding one dep to requirements.in, or a periodic
refresh, when you DON'T want a mass upgrade):
with_private_git uv pip compile requirements.in \
--override requirements-private.txt --refresh \
--generate-hashes --no-annotate --python-version 3.13 \
--python-platform x86_64-unknown-linux-musl \
-c requirements.txt \
-o requirements.full.txt
grep -vE '^(internal-core|internal-models) @ git\+' requirements.full.txt > requirements.txt
rm requirements.full.txtDeliberate upgrade (bump one specific package):
Prepare /tmp/upgrade-constraints.txt as a copy of requirements.txt with only the
target package's complete requirement entry removed (including continued hash
lines). Keep all other pins. Review that small constraints diff before compiling:
with_private_git uv pip compile requirements.in \
--override requirements-private.txt --upgrade-package NAME \
--generate-hashes --no-annotate --python-version 3.13 \
--python-platform x86_64-unknown-linux-musl \
-c /tmp/upgrade-constraints.txt \
-o requirements.full.txt
grep -vE '^(internal-core|internal-models) @ git\+' requirements.full.txt > requirements.txt
rm requirements.full.txtReplace NAME with the target distribution name. --upgrade-package does not
override explicit constraints; leaving its old pin in the temporary constraints
would prevent the upgrade. If the new version conflicts with another pinned
dependency, review and relax that pin deliberately too. Do not drop the constraints
file entirely: that would re-resolve the whole tree (see gotcha 16).
Deliberate full-tree upgrade (bump everything to newest within the gate):
with_private_git uv pip compile requirements.in \
--override requirements-private.txt --upgrade \
--generate-hashes --no-annotate --python-version 3.13 \
--python-platform x86_64-unknown-linux-musl \
-o requirements.full.txt
grep -vE '^(internal-core|internal-models) @ git\+' requirements.full.txt > requirements.txt
rm requirements.full.txtThe only mode with no third-party constraints — you want the mass re-resolve. Review the resulting diff carefully; this is the mode that loses the "reproduces what you tested" guarantee.
--override requirements-private.txtforces uv to resolve those packages from your token-free URLs instead of the{env:CR_PAT}@HEADones in their metadata.--refreshon routine recompiles refreshes cached first-party HEAD metadata so changed dependency declarations are considered. It does not relax the third-party constraints in-c requirements.txt; resolve conflicts deliberately.-c requirements.txton the recompile is not optional in this flow. The Step 2 "uv preserves pins on recompile" property does NOT apply here — uv reads pins from the-ooutput file, and Step 5b's output is a throwaway (requirements.full.txtthat getsrm'd). Without-c requirements.txt, adding a single dep torequirements.insilently re-resolves the ENTIRE tree to newest-within-the-exclude-newer-gate. That is the exact exposure this skill exists to prevent, and the 7-day gate is your only remaining line of defense when it happens. Do not omit.--no-annotateis not cosmetic here either. The grep-strip above removes the private-lib lines but is not annotation-aware; with annotations enabled, those libs' multi-line# via ...blocks get orphaned and visually attach to the next alphabetical package (real observed case: three stackedviablocks including--override requirements-private.txtprovenance ended up undermarshmallow, which a reader would reasonably conclude is a direct private dependency).--no-annotatedrops all# viablocks — you lose the provenance trail, which is the accepted trade for a lock that isn't a maintenance trap. If you want provenance back, replace thegrep -vEwith a range-aware awk that drops each matched line's trailing indented# viablock too.- Note any transitive public git deps that remain in the lock (e.g. a logging
lib pulled from GitHub) — they're unhashable, which is why
--require-hashesneeds the Step 5 split. Leave them inrequirements.txtso the--no-depsinstall below can satisfy them.
The drift rule — first-party lib dep changes must trigger a backend recompile. The Dockerfile below installs first-party libs
--no-depsat HEAD. Their third-party sub-tree was frozen intorequirements.txtat compile time. So if a first-party lib adds a new PyPI dep (or bumps one meaningfully) and you merge that lib change WITHOUT rerunning the "routine recompile" above in the backend repo, the next backend deploy will pull the lib's new HEAD, install it with--no-deps, and failuv pip check(or an import smoke test). This is the specific cost of "always-latest first-party + pinned third-party" — the two sides can silently diverge. Two options:
- Convention: any change-set that adds a runtime dep to a first-party lib also includes a
requirements.inrecompile in the backend, shipped together.- CI check: make
uv pip check --systemfail the image build on missing or incompatible declared dependencies, then run a smokepython -c "import <backend_pkg>"in the built image for import-time failures.
Dockerfile — replace the Step 4 install RUN with the following guarded split. Run from
the project working directory and copy the Step 3 configuration before installing
(uv.toml below; use pyproject.toml instead if it holds [tool.uv] and no
uv.toml takes precedence):
COPY uv.toml requirements.txt requirements-private.txt ./
# Keep the scanner COPY from Step 4.
RUN --mount=type=secret,id=cr_pat,required=true \
set -eu; set +x; \
CR_PAT="$(cat /run/secrets/cr_pat)"; test -n "$CR_PAT"; \
export GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="url.https://${CR_PAT}@github.com/.insteadOf" \
GIT_CONFIG_VALUE_0="https://github.com/"; \
uv pip install --system --no-cache -r requirements.txt; \
uv pip install --system --no-cache --no-deps -r requirements-private.txt; \
uv pip check --system; \
python /usr/local/bin/assert_no_secret.py --secret-file /run/secrets/cr_pat \
/usr/local/lib /app /etc /root /tmp--no-depsassumes all runtime dependencies are already supplied by the lock and the complete private install list. If the application itself is a package, also install it with--no-depsinside this RUN before the check/scan.--no-depsdoes not disable isolated build dependencies.- Keep configuration discovery enabled.
exclude-newerapplies to registry packages, including dependencies resolved for isolated builds, not Git commit dates. A new first-party commit can install while its registry build tools remain age-gated.--no-configwould discard that gate along with other discovered settings. See uv's resolution documentation and install options. - If a registry build dependency is too new for the selected gate, retain the gate and use a compatible eligible version or wait for it to age in; do not bypass configuration to make the Git install pass.
uv pip check --systemdetects missing or incompatible declared runtime dependencies after both installs. On failure, refresh/recompile the lock and rebuild; do not remove the check or resolve fresh runtime deps during the build.
⚠️ The cache trap (bites every rebuild). A RUN uv pip install ... requirements-private.txt
layer is cached on the command string + the file's contents — neither changes when
the upstream branch moves, so Docker silently reuses the old commit and "always
latest" quietly becomes "whatever was latest the first time." To actually pull the
newest first-party code you must docker build --no-cache (or bust the cache above
that layer, e.g. an ARG GIT_REV passed each build). This is also why, right after
merging a fix to a first-party lib, the next publish must be --no-cache.
Implement this in build-publish.sh and CI when HEAD tracking is chosen. Add
unconditional --no-cache to the build invocation (including buildx builds), while
retaining --secret id=cr_pat,env=CR_PAT. This trades build speed for freshness;
do not leave it as a flag humans must remember. An existing per-build cache-bust
that always invalidates the private-install layer is also valid. uv's --no-cache
inside the RUN cannot invalidate Docker's layer cache. Place the Step 6 verification
between build and push; do not use a combined buildx --push before local checks.
Step 5c: Harden dev-requirements.txt Too (optional)
Steps 1–5b lock the runtime dependency install. Dev tooling
(pytest / black / mypy / ruff / etc.) installed via dev-requirements.txt
is outside that scheme entirely — unpinned, unhashable, ungated, and installed on
exactly the machines that have CR_PAT exported. A compromised pytest release
served to a CI runner has the same reach as a compromised runtime dep, and the
runtime hardening does nothing to catch it.
First inspect the existing dev file and all CI/local install commands. Move only
direct dev tooling into dev-requirements.in. Remove legacy runtime includes such
as -r requirements.txt from this input; constrain the dev compile against the
runtime lock instead. Regenerate dev-requirements.txt rather than hand-editing its
lock entries. Under Step 5b, update consumers to install all three files: the runtime
lock, dev lock and complete private list. Merely keeping the old runtime include
silently omits first-party packages removed from that lock.
The same recipe applies, one file over:
# dev-requirements.in — direct dev deps only.
pytest
pytest-cov
black
mypy
ruff# Compile with the RUNTIME lock as a constraint so shared transitive deps
# (e.g. `packaging`) can't resolve to a conflicting version between the two
# files. This keeps `uv pip install -r requirements.txt -r dev-requirements.txt`
# internally consistent.
uv pip compile dev-requirements.in \
--generate-hashes --no-annotate --python-version 3.13 \
--python-platform x86_64-unknown-linux-musl \
-c requirements.txt \
-o dev-requirements.txtBefore the local install, check where the project's venv lives. With the
python3 -m venv . convention used by [[python-project-scaffold]] and
[[python-lib-setup]], uv writes a transient .lock file at the repository root.
Merge this exact rule into the existing .gitignore once, preserving its other
entries:
# uv writes this transient lock into the venv root (= project root here).
/.lockFor a venv in an already-ignored directory such as .venv/, its .lock is
already covered. Do not use *.lock or ignore uv.lock: dependency lockfiles
are distinct from this environment lock. If the root .lock was already
staged or committed, confirm it is the uv-generated artifact, then run
git rm --cached -- .lock to stop tracking it while retaining the local file.
Check git check-ignore -v -- .lock and review git status --short before
committing.
Local install — use uv pip install, not plain pip:
# Without the Step 5b split:
uv pip install -r requirements.txt -r dev-requirements.txt
uv pip check
# With Step 5b (use the Step 1 auth helper for private repos):
with_private_git uv pip install -r requirements.txt -r dev-requirements.txt
with_private_git uv pip install --no-deps -r requirements-private.txt
uv pip check- Plain
pipbreaks on the Step 5b lock.requirements.txtunder the split flow intentionally retains unhashable transitive public git deps (see the note on transitive git deps above). pip enters hash-required mode as soon as it sees a hashed line and then aborts withHashes are required in --require-hashes modeon the unhashable git line. uv is more forgiving — it verifies present hashes without demanding them on every line. - Plain
pipdoes not honorexclude-newer. That setting lives inpyproject.toml/uv.tomland only uv reads it. Running pip here would install fresh-off-PyPI dev tooling despite the rolling gate — the exact class of attack Step 3 was set up to block. - Skip this step for projects with no dev-tooling install path (a service repo whose CI runs a prebuilt test image, say). The point is coverage of every install path where PyPI can reach the machine.
Step 6: Verify the Build — and That the Token Did Not Leak
Build and verify before pushing. Use the token already loaded securely into the environment; do not paste it into shell commands or enable tracing. Preserve the project's registry/tag substitution and add these checks to its publish script. This example is for private-dependency builds; public-only projects omit the secret input and retain their build/dependency/smoke checks. Previously exposed credentials still need artifact verification even if the current build no longer uses them. The following Bash example uses a local test tag and a temporary directory whose contents may contain a leak, so it is private and removed on exit:
set -euo pipefail
set +x
: "${CR_PAT:?Load the private Git token first}"
export CR_PAT
export DOCKER_BUILDKIT=1
verify_dir="$(mktemp -d)"
trap 'rm -rf -- "$verify_dir"' EXIT
# Capture instead of streaming potentially credential-bearing build output.
if ! docker build --no-cache --secret id=cr_pat,env=CR_PAT -t app:test . \
>"$verify_dir/build.log" 2>&1; then
python3 dev_scripts/assert_no_secret.py --secret-env CR_PAT "$verify_dir/build.log"
echo "Build failed; rerun with private log retention if diagnosis is needed" >&2
exit 1
fi
docker image inspect app:test >"$verify_dir/inspect.json"
docker history --no-trunc app:test >"$verify_dir/history.txt"
docker image save --output "$verify_dir/image.tar" app:test
python3 dev_scripts/assert_no_secret.py --secret-env CR_PAT \
"$verify_dir/build.log" "$verify_dir/history.txt"
python3 dev_scripts/assert_no_secret.py --secret-env CR_PAT \
--json "$verify_dir/inspect.json"
python3 dev_scripts/assert_no_secret.py --secret-env CR_PAT \
--docker-archive "$verify_dir/image.tar"
# Run the project's image startup/import smoke tests here, then push only on success.The scanner accepts any nonempty single-line token, independently of its variable
name or provider prefix. It never prints matches, filenames or raw exceptions.
Exit 1 means a match; 2 means an incomplete scan (bad/missing input, read
error, unsupported archive); both fail the build/publish. Do not use grep ... ||
echo clean: that treats errors as success. Secret IDs such as cr_pat appearing
in Dockerfile history are not evidence of a leaked value.
The in-build scan covers regular files and file/link names in the specified roots without following symlinks. JSON inspection and saved image configuration are also checked after JSON decoding, so escaped characters cannot hide a token. The Docker-save scan checks config/history and every saved layer's file contents and link names, including files deleted by a later layer and gzip-compressed layers. It does not run the image or extract files. This checks literal bytes for the supplied token, not encoded/obfuscated secrets or unknown historical tokens. If older tokens are available, scan for their values through separate secret inputs; provider-pattern scans can supplement, but cannot replace, exact-value checks.
After an authorized push, verify the registry digest and pull/scan that immutable reference with the same commands (no need to delete local tags). If the pipeline publishes build cache or intermediate stages, verify those artifacts too; a final image scan cannot certify artifacts it does not contain. Do not publish a failed build's logs or caches. If a token was exposed in a previously published artifact, report it without repeating its value and recommend rotation with its owner.
Gotchas Worth Remembering
These are the non-obvious things that cost time the first time through:
- Compile without constraints jumps to latest. Always pass
-c <pip-freeze-output>on the first compile, or you'll lock to versions you never tested. (Step 2.) - uv keeps existing pins on recompile — in the single-lock flow only. Adding
one dep to
requirements.inand recompiling won't bump the rest, because uv reads pins from the-ooutput file. Only true when the-ofile persists between compiles. Step 5b's split flow rms its-otarget and needs an explicit-c requirements.txt— see gotcha 16. (Step 2 / Step 5b.) exclude-neweris rolling and applies to install too, not just compile. Auv pip installinside Docker is also gated, so a base-image rebuild won't pull a day-old package either. (Step 3.)- The build backend is a dependency too. Pinning app deps but leaving
requires = ["hatchling"]floating leaves a hole at wheel-build time. (Step 3.) - Build args are not secret storage.
ARGcan expose values in history and provenance;ENValso stores them in image config. Use required BuildKit secret mounts, process-only Git auth and value-based scans that fail on errors. (Step 4/6.) - Git deps are unhashable — the commit SHA is their integrity anchor, which is
why Step 1 insists on pinning them by SHA, and why
--require-hashesneeds a split. (Step 1/5.) - Pin the uv binary by digest, not just tag — it's the root of trust for the whole install. (Step 4.)
{env:CR_PAT}in a library's own pyproject bakes the PAT into its wheel metadata. hatchling expands it at build time intoRequires-Dist, so the token ships in every image — invisible to env/history checks. Scan*.dist-info/METADATA, fix the lib to token-free URLs, rotate the PAT. (Step 1/6.)- uv resolves the whole graph; pip dedups by name. A private lib that declares
its own
{env:CR_PAT}/unpinned deps will fail or conflict the compile. Use--overrideto force those packages to your chosen source. (Step 5b.) - uv pins git deps to a resolved commit even from a no-SHA input. A single lock
therefore freezes first-party libs to compile-time HEAD — split them out and
install
--no-depsat HEAD if you want always-latest. (Step 5b.) - Docker caches HEAD git installs. The
requirements-private.txtinstall layer reuses the old commit until you--no-cache(or cache-bust). "Always latest" silently rots otherwise — and a just-merged lib fix won't ship without it. (Step 5b.) - Compile for the container's Python and platform. Set
--python-versionand--python-platformon every compile, including the split and dev flows; match the image's libc and architecture too. (Step 0/2.) - A raw
pip freezebreaks-c. Strip VCS/editable/file://lines (keep onlyname==version) before using it as a constraints file. (Step 2.) - Keep the registry age gate during first-party Git installs.
exclude-newerdoes not filter Git commits, but does filter registry dependencies needed by their isolated builds.--no-configdrops that protection; keep the Step 3 configuration available during both Docker installs. (Step 5b.) - An installed pin can be too new for the gate. Errors can say "no version
of X==Y" or show an unsatisfiable chain: "A requires B==Y; available B ends at
Y-1". In the second case, investigate B, not just top-level A. Confirm the
rejected artifact's registry upload time and the effective cutoff; platform or
Python incompatibility can look similar. Iterate: identify one gate-blocked
pin, deliberately relax only that constraint in
/tmp/constraints.txt, rerun the same compile with the gate intact into a candidate, and obtain approval for any resulting downgrade before replacing the lock (Step 0/2). If A's own metadata insists on the too-new B, choose a compatible older A as well or wait for B to age in. Stop if no compatible eligible solution exists; do not automatically delete arbitrary pins or disable configuration. (Step 2.) - Step 5b's
-ois a throwaway;-c requirements.txtis mandatory on recompile. Unlike Step 2's single-lock flow, Step 5b compiles torequirements.full.txtandrms it — uv's pin-preservation-on-recompile behavior does nothing here because there's no persistent-otarget to read from. Omitting-c requirements.txton the routine recompile silently re-resolves the entire tree to newest-within-the-gate. The 7-day age gate is your only remaining defense when this happens. (Step 5b.) - The
--no-depssplit lets first-party libs drift ahead of their locked sub-tree. Because backend third-party deps are frozen at compile time but first-party libs install--no-depsat HEAD, a first-party lib that adds a new runtime dep and merges without a backend recompile deploys, imports, and dies withModuleNotFoundError. Convention: lib dep-change PR must include a backendrequirements.inrecompile. CI catch:uv pip check --systemafter both installs, plus a smokeimportin the built image. (Step 5b.) - Dev tooling is outside the runtime hardening.
pytest/black/ etc. installed from an unpinneddev-requirements.txtland on the same machines that holdCR_PAT. A hijacked pytest release has the same reach as a hijacked runtime dep, and nothing in Steps 1–5b catches it. Compile a hasheddev-requirements.txtthe same way, constrained byrequirements.txtso shared deps can't conflict. (Step 5c.)
Design Principles
- Preserve the agreed runtime baseline — compare against production and explicitly approve version changes; a local freeze is not a production inventory.
- Make tampering detectable — hashes on every PyPI artifact.
- Buy time against fresh malware — a rolling release-age gate.
- Pin the whole chain — app deps, the build backend, the uv binary, and (via commit SHA) Git deps. A single floating link defeats the rest.
- Keep credentials out of artifacts — BuildKit secrets and process-only Git auth; fail on secret values or scan errors in logs, config, history and layers.
- Decide tradeoffs out loud — e.g. skipping
--require-hashesis fine, but say so and say why, rather than leaving it silently unaddressed.
Composes With
- flask-docker-deployment / mcp-docker-deployment — run this after the Docker build exists to harden its dependency install (replaces the pip flow).
- python-lib-setup — when your internal libraries are the Git deps being pinned by commit SHA here.
Reporting problems with this skill
If you find a bug or stale instruction while using this skill:
- If HiveMake is connected and
byteforge-skills-maintainer-agentis available, file a ticket with that agent. Usediscover_agentsto find its project ID. - Otherwise, open an issue at GitHub Issues. If you cannot submit a report, give the human the details and this link.
Include the skill name, plugin version if known, the instruction followed, expected and actual results, relevant errors/runtime versions, and any fix or workaround that worked. Omit credentials and private data.
Address the user's immediate problem first; report the skill defect afterward.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

