A dependency firewall for pip, poetry, and uv. It scores every package in your dependency graph against supply-chain heuristics and refuses the install if something looks wrong.
Local CLI, no service, no account, no YAML. Configuration lives in pyproject.toml, environment variables, and flags.
pandas3 is a real package on PyPI today. It is one character from pandas, ships no wheel, and has one release from 2018.
$ depwal install pandas3
depwal: requested packages
package version risk verdict reason
pandas3 0.0.1 100 BLOCK risk 100 >= threshold 70
pandas3 signals:
+ 45 typosquat one edit away from popular package 'pandas'
+ 20 sdist_only no wheel; setup.py executes on install
+ 15 single_release only one release ever published
+ 15 abandoned no release in 2514d
+ 15 no_repo no source repository declared
1 package(s) blocked
blocked before resolution; nothing was downloaded
That last line is the point. pip never ran.
pip install runs arbitrary code from the internet as your user. That is well known.
Less well known: so does pip install --dry-run. For any package that ships an sdist and no wheel, pip downloads it and executes its setup.py to read the metadata. Resolution is not a read-only operation. A tool that "checks a package before installing it" by resolving it first has already lost.
depwal is built around that fact:
check,audit, andexplainread PyPI and GitHub metadata over HTTPS. They never download a distribution and never execute package code.installandaddscreen the packages you named from metadata alone, before any resolver touches them. Then they resolve the transitive closure wheels-only (--only-binary :all:), so nosetup.pyruns during resolution. Then they gate the closure. Only then does the real install happen.- Path, URL, and VCS specs are refused by default, because pip always builds those.
If some package in your graph genuinely ships no wheel, depwal stops and tells you that resolving it means running its code, and you can opt in with --allow-sdist.
pipx install depwal # or: pip install depwalPython 3.11+. Dependencies: typer, rich, certifi.
depwal check # gate poetry.lock / uv.lock / requirements.txt, exit 1 on block
depwal check --req requirements.txt
depwal install fastapi # gate the closure, then pip install
depwal add fastapi # gate the closure, then poetry add
depwal audit # score what is already installed
depwal explain pandas3 # full signal breakdown for one package
depwal explain requests 2.31.0 # ...at a specific version
depwal explain psf/requests # score a GitHub repository directly
depwal cleanup # delete the metadata cache--json writes a machine-readable report to stdout; the human table goes to stderr, so you can pipe one without losing the other.
explain also takes a GitHub owner/repo or URL and scores the repository signals alone (archived, stale, fork, stars) -- useful for a dependency that is not on PyPI yet.
Make the firewall the default instead of something you have to remember to invoke:
alias pip='depwal alias-pip'
alias poetry='depwal alias-poetry'
alias uv='depwal alias-uv'pip install, poetry add, and uv add are then gated exactly like depwal install / depwal add. Every other subcommand -- pip list, poetry lock, uv run, all of it -- passes through untouched with the original exit code. To bypass the gate once, prefix the command with a backslash (\pip install ...), which skips the shell alias.
Editable installs (pip install -e) and path/URL/VCS specs are refused in alias mode, because pip always builds those before depwal could judge them.
depwal check already understands uv.lock. For installs, uv add normally locks and installs in one step, so alias-uv splits it: it locks with uv add --no-sync --no-build (no package code can run), gates the closure that landed in uv.lock, and only then runs uv sync. On a block, pyproject.toml and uv.lock are restored to what they were.
Signals are collected from the PyPI JSON API and the GitHub REST API, cached for 24h in ~/.cache/depwal (depwal cleanup wipes it), and fetched in parallel. Each rule adds points; the total is clamped to 100.
Every heuristic lives in one module, depwal/score.py, about a hundred lines with no I/O in it. Read it before you trust it; that is the whole rulebook.
| rule | points | signal |
|---|---|---|
pkg_missing |
100 | not on PyPI |
version_missing |
100 | that version was never published |
version_yanked |
100 | this exact version was yanked |
all_yanked |
60 | every release is yanked |
typosquat |
45 | one edit from a top-5000 package name |
owner_changed |
30 | maintainer email differs from the previous release |
pkg_new |
30 | first published under 30 days ago |
repo_archived |
30 | GitHub repo archived or disabled |
dormant_release |
25 | released after over a year of silence |
repo_moved |
25 | repository owner changed since the previous release |
repo_missing |
25 | declared repository is gone or private |
sdist_only |
20 | no wheel, so setup.py executes on install |
license_changed |
20 | license differs from the previous release |
pkg_very_new |
15 | under a week old, stacks with pkg_new |
abandoned |
15 | no release in two years |
single_release |
15 | only one release ever |
no_repo |
15 | no source repository declared |
repo_stale |
15 | no push in a year |
repo_low_stars |
10 | under 10 stars |
repo_fork |
10 | repository is a fork |
The first three are hard rules: they score 100 and suppress every other finding.
Account takeover has a shape. A package goes quiet for a year, then publishes a release with a new maintainer address and a rewritten license. That is dormant_release + owner_changed + license_changed = 75, over the default threshold, on its own.
Metadata changes are diffed against the previous release on PyPI, not against a stored baseline, so there is no state file to commit and nothing to bootstrap.
allowglob matches, the package is allowed, score ignored.denyglob matches, the package is blocked.risk >= risk_threshold, the package is blocked (mode = "block") or flagged (warn,audit).
Names are normalized per PEP 503 before matching, so Zope.Interface and zope-interface are the same package.
# pyproject.toml
[tool.depwal]
risk_threshold = 70
mode = "block" # block | warn | audit
[tool.depwal.allow]
packages = ["requests", "fastapi", "pydantic"]
[tool.depwal.deny]
packages = ["*"] # default-deny: nothing but the allowlist gets inPrecedence is flags > environment > pyproject.toml > defaults. Every setting has a DEPWAL_ variable (DEPWAL_RISK_THRESHOLD, DEPWAL_MODE, DEPWAL_ALLOW, DEPWAL_DENY, DEPWAL_LLM_COMMAND, DEPWAL_OFFLINE, ...). --allow and --deny append to the configured lists rather than replacing them.
Set GITHUB_TOKEN to lift the 60 requests/hour anonymous GitHub limit. Without it, depwal simply scores the PyPI signals alone; a rate-limited API is never read as a missing repository.
--offline skips the network entirely and enforces only the allow and deny lists.
Point depwal at any command that reads a prompt on stdin and writes text on stdout.
depwal --llm "claude -p" check
export DEPWAL_LLM_COMMAND="ollama run qwen2.5-coder"It runs only on packages in the gray band: those that passed the heuristic but scored at or above --llm-floor (default 40). Below that, a call is not worth the latency. Above the threshold, the package is already blocked.
Two properties matter:
- The LLM can only tighten a verdict, never loosen one. It can turn an allow into a block. It can never un-block something the heuristic rejected. A malicious package's own metadata is part of that prompt, so it must never be able to argue its way past the gate. The prompt says as much, and
escalate()enforces it regardless of what the model returns. - It fails open. A timeout, a nonzero exit, or unparseable output leaves the heuristic verdict untouched. Your build does not break because a model was slow.
Only computed signals are sent: name, version, score, and the rules that fired. Package descriptions and READMEs are never forwarded, because that is the obvious prompt-injection channel.
- run: pipx install depwal
- run: depwal check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Exit codes: 0 allowed, 1 blocked, 2 error. mode = "warn" and mode = "audit" always exit 0, so you can roll it out in report-only mode before turning it on.
As a pre-commit hook:
- repo: local
hooks:
- id: depwal
name: depwal
entry: depwal check
language: system
pass_filenames: false
files: ^(poetry\.lock|uv\.lock|requirements\.txt)$harness/showhn.py is the field test and the regression tracker for the heuristics. It works in three steps:
python harness/showhn.py collect # freeze this week's corpus -> harness/data/29.2026.csv
python harness/showhn.py score # score it -> harness/data/29.2026.scores.0.1.0.csv
python harness/showhn.py diff 29.2026 0.1.0 0.2.0collect pulls the newest Show HN posts, keeps the ones linking a GitHub repo that ships a Python manifest (uv.lock, poetry.lock, requirements.txt, or pyproject.toml), and freezes their dependencies as a CSV named after the ISO week (collect 20 5 limits it to 20 projects from 5 HN pages; default 100 from 10). Nothing is downloaded or installed; it is the same metadata-only scoring check uses.
score runs the installed depwal over a frozen corpus and files one row per package -- risk, verdict, rules fired -- under the current depwal version. To see what a heuristic change does, score with one version, check out or install the other, and score again: diff then prints every package whose risk, verdict, or fired rules moved between the two files.
Score both versions within the cache TTL (24h) and the second run reads the same cached PyPI/GitHub metadata as the first, so the diff isolates the rule change from real-world drift. Set GITHUB_TOKEN before the first run; a hundred projects will exhaust the anonymous GitHub rate limit quickly (depwal then degrades to PyPI signals alone). Expect false positives -- fresh Show HN projects are, by definition, new packages by unknown authors, which is exactly what the heuristics distrust. That makes it a good calibration corpus.
Being honest about the boundary, because a security tool that oversells itself is worse than none.
- It does not inspect package contents. No sdist unpacking, no AST analysis, no scanning for
evalor exfiltration. That would mean downloading the artifact, which is the thing depwal exists to avoid doing before a verdict. - It does not detect a compromised release from a legitimate maintainer who keeps the same email, license, and repository. Metadata heuristics cannot see that. Nothing here replaces pinned hashes.
- It is not a CVE scanner. Use
pip-auditorosv-scanner; they answer a different question. depwal asks "does this package look like it was taken over," not "does this version have a known bug." auditis detection, not prevention. By the time a package is installed, itssetup.pyhas already run.- Heuristics have false positives. A quiet, single-release, sdist-only package by a solo author with no GitHub stars is indistinguishable from a fresh typosquat on metadata alone. Start in
mode = "warn", look at what fires, then allowlist and tighten. - The top-5000 list is bundled and goes stale. It refreshes on release, not on a schedule.
- Metadata diffing compares against the immediately preceding release, including prereleases.
pip-audit and osv-scanner for known vulnerabilities. guarddog for content-based malware heuristics, which does download and analyze the package. Socket.dev for the commercial, hosted version of roughly this idea. depwal is the small local one that runs before the download.
MIT