Skip to main content

package-alert

package-alert monitors Python, Node.js, and PHP package installations in real time and alerts developers when a malicious or suspicious package is detected. It can also run package manager commands inside an isolated bubblewrap sandbox with pre-flight and post-install OSV checks, scan project lock files or installed environments on demand, and schedule automatic daily or weekly scans across multiple projects.

Features

  • Real-time process monitoring — detects pip install, uv add, uv sync, pipenv install, npm install, composer require, and more as they happen
  • Lock file scanning — after a lock-file-based install finishes, reads the lock file for exact package versions and scans all of them in a single OSV batch call
  • Cache monitoring — watches pip/uv/npm cache directories for newly downloaded packages
  • OSV.dev integration — checks every package against the Open Source Vulnerabilities database
  • Heuristic risk scoring — detects suspicious packages even without a known advisory
  • Typosquatting detection — flags packages that closely resemble popular libraries (Levenshtein distance)
  • Popularity signal — queries deps.dev to flag packages with very few versions or dependents
  • Low latency alerts — Rich terminal panel + notify-send desktop notifications
  • Alert history — all alerts persisted in SQLite with package name, version, advisory, and project path
  • Sandboxed installs — package-alert run wraps any package manager command in a bubblewrap sandbox with pre-flight and post-install OSV checks
  • Shadow tools — setup shell and setup project install transparent interceptors so pip, npm, uv, etc. route through package-alert automatically — no prefix needed, works for interactive use, coding agents, and python -m pip
  • Cooldown policy — blocks or prompts before installing packages published within a configurable window (default 7 days); escalates automatically in non-interactive contexts (CI, coding agents)
  • Language introspection — package-alert languages list and package-alert languages info show loaded language modules and their capabilities

Supported Ecosystems

Ecosystem Package managers monitored Lock files scanned
PyPI pip, python -m pip, uv add, uv sync, uv lock, pipenv install uv.lock, Pipfile.lock, requirements.txt
npm npm install, npm add, npm ci, yarn add, yarn install, pnpm add, pnpm install package-lock.json, yarn.lock, pnpm-lock.yaml
Packagist composer install, composer update, composer require, php composer.phar … composer.lock

Installation

System requirements

inotify limits — the daemon watches npm/pip/uv cache directories with inotify. Two separate limits matter here, and raising only one can leave you still hitting ENOSPC:

  • fs.inotify.max_user_watches — max files a user can watch across all inotify instances. The default Linux limit (8,192–65,536 watches depending on distro) is often exhausted by VS Code, JetBrains, or other tools running alongside the daemon. Raise it to the value VS Code itself recommends.
  • fs.inotify.max_user_instances — max separate inotify instances (file descriptors) a user can open at once. Defaults to 128 on most distros and is easy to exhaust if you run several file-watching tools (editors, dev servers, the daemon) concurrently.
printf 'fs.inotify.max_user_watches=524288\nfs.inotify.max_user_instances=512\n' | sudo tee /etc/sysctl.d/99-package-alert.conf && sudo sysctl --system

This persists across reboots. Without both limits raised you may see ENOSPC: System limit for number of file watchers reached errors in file watchers after the daemon has been running for a while — the same error is raised whether it's the watch count or the instance count that's exhausted, so raise both rather than trying to diagnose which one hit its limit.

uv (recommended):

uv tool install package-alert

pipx:

pipx install package-alert

Both install package-alert and pa into an isolated environment and make them available system-wide. pa update works with either.

Try it without installing — scan a project's lock files for vulnerabilities using uvx:

uvx package-alert scan-project /path/to/project

Development install:

uv tool install -e .   # or: pipx install -e .

The installer registers two entry points: package-alert (full name) and pa (short alias). Both are identical — use whichever you prefer:

pa daemon          # same as: package-alert daemon
pa run npm install # same as: package-alert run npm install

Shell completions work correctly with both names. To generate completions for pa, run the appropriate command for your shell (e.g. pa --install-completion).

Quick Start

# Start the daemon (foreground)
package-alert daemon

# Start the daemon and return immediately to the shell
package-alert daemon --background

# Check daemon status
package-alert status

# Install shell integration — pip, uv, npm etc. are intercepted transparently
package-alert setup shell --install   # adds eval line to ~/.bashrc or ~/.zshrc
source ~/.bashrc                       # activate in current shell

# Install project-local shims (catches coding agents that bypass the shell)
package-alert setup project

# Run a package manager command in a sandbox explicitly
package-alert run uv sync
package-alert run npm install

# Scan the current project's lock files for vulnerabilities
package-alert scan-project

# Scan an explicit requirements file (e.g. a pinned CI lockfile)
package-alert scan-project -r requirements-lock.txt

# Query a specific package
package-alert query requests 2.31.0

# View recent alerts
package-alert alerts

# Pre-clear a package to bypass cooldown (e.g. for unattended agent installs)
package-alert cooldown allow requests 2.32.0

Commands

Global options

Option Description
--verbose / -v Print log output to the console. Without this flag log output is written only to the configured log file.
--config / -c Path to a TOML config file (overrides the default ~/.config/package-alert/config.toml).

daemon

Start the monitoring daemon. Only one instance may run at a time (enforced via a PID file at ~/.local/share/package-alert/daemon.pid).

package-alert daemon [--background] [--config PATH]
Option Description
--background / -b Fork into the background and return immediately. Without this flag the daemon runs in the foreground (useful with systemd, Docker, or a terminal multiplexer).

The daemon:

  1. Polls running processes every second for package manager invocations
  2. Watches pip/uv/npm cache directories with inotify for newly downloaded wheels/tarballs
  3. Dynamically registers site-packages directories when a venv install is detected
  4. Waits for lock-file-based installs (npm, uv sync/lock, pipenv, composer) to finish, then reads the lock file and sends all packages to OSV in a single batch call
  5. Checks each package against OSV; fires alerts for malicious packages or those exceeding the heuristic risk threshold

run

Run a package manager command inside a bubblewrap sandbox.

package-alert run [--no-network] [--no-change] <command> [args...]

Examples:

package-alert run uv sync
package-alert run uv add httpx
package-alert run pip install requests flask==3.0.0
package-alert run npm install
package-alert run npm install lodash@4.17.21
package-alert run composer install
package-alert run --no-network uv sync          # fully offline; uv cache must be warm
package-alert run --allow-external-lockfiles uv sync  # monorepo with symlinked lock files
package-alert run -n pipenv lock                # audit what would be locked without keeping it
package-alert run bash                          # interactive sandboxed shell

What it does:

  1. Pre-flight check — identifies what will be installed (from the command arguments or the project lock file) and batch-queries OSV before anything runs. Blocks immediately if a known-malicious package is found.
  2. Sandboxed execution — runs the command inside a bubblewrap namespace with layered filesystem isolation (see below). Network access is allowed by default so package managers can reach their registries; use --no-network only when all packages are already cached locally.
  3. Post-install scan — diffs the install targets against a pre-run snapshot, identifies new packages by their metadata files (.dist-info, package.json, composer.json), and runs another OSV check on everything that appeared.
Option Description
--no-network Block all outbound network inside the sandbox. Use only when all packages are already in the local cache.
--env VAR Pass an additional environment variable through into the sandbox. Repeatable: --env MY_TOKEN --env CUSTOM_URL.
--flags CAPABILITY[,…] Enable named capabilities for this run, e.g. --flags python:ssh-keys to expose ~/.ssh read-only inside the sandbox. Required when installing packages with git+ssh:// or scp-style (git@host:org/repo) VCS dependencies. package-alert detects these automatically and suggests the flag if it is not passed. To make flags persistent for a project, use .pa-run.toml (see below). Use --flags python:uv-auth to expose a writable snapshot of uv's credential store when uv sync/uv lock fails with a 401 against a private index on a cold cache (note: install-time code can read the credentials).
--expose-ssh-keys (Deprecated — use --flags python:ssh-keys instead.) Equivalent to --flags python:ssh-keys. Will be removed in a future release.
--allow-external-lockfiles Disable symlink containment checks on lock files. Use in monorepo or editable-install setups where lock files are symlinks pointing outside the project root. Without this flag, lock files that resolve outside the project are rejected at every stage — pre-flight scan, post-run lock-file scan, snapshot, and restore — to prevent a malicious install from reading or writing arbitrary paths via a redirected lock file symlink.
--no-change / -n Dry-run mode. Runs the command in the sandbox and performs all pre- and post-checks, but always restores lock files to their pre-run state on exit regardless of outcome. Useful for auditing what a command would install without committing changes to the project.
--allow-project-env Bypass the sandbox.project_env_allowlist check for this run only. Allows an untrusted .pa-run.toml to forward env vars that are not in the allowlist.
--config PATH Path to config TOML file.

Per-project defaults — .pa-run.toml:

Place a .pa-run.toml file in a project directory to set default options for every pa run invocation in that tree. This is the recommended way to persist flags such as python:ssh-keys for projects with SSH VCS dependencies.

# .pa-run.toml
flags = "python:ssh-keys"          # merged with any --flags passed on the CLI
env   = ["MY_PRIVATE_TOKEN"]       # merged with --env
no_network             = false     # set true to always run offline
allow_external_lockfiles = false   # set true for monorepo symlinked lock files
allow_major = ["cryptography"]     # pa fix only: packages whose major upgrades may be planned (trusted files only)

Discovery: starting from the current working directory, package-alert walks up the directory tree looking for .pa-run.toml. The first (closest) file found wins, and its path is printed to the console so the source is always transparent. Two boundaries apply: the walk never goes above $HOME, and if the project is outside $HOME the walk stops at the first VCS root (.git/.hg) it encounters. For projects under $HOME, VCS roots are not stopping points — they only determine whether the file is trusted (see Security below). A ~/.pa-run.toml acts as a user-wide default for projects under $HOME; a monorepo can carry a shared file at its repo root.

Precedence: .pa-run.toml < PA_RUN_OPTS < explicit CLI flags. The flags option is unioned across all three sources — --flags python:network on the command line adds to the project defaults rather than replacing them. The env option is unioned between .pa-run.toml and CLI only (PA_RUN_OPTS does not support --env). Boolean options (no_network, allow_external_lockfiles) are OR-ed: once set to true in any source they cannot be unset by a lower-precedence source.

Security — env vars from untrusted configs: A .pa-run.toml at or below a VCS root (.git/.hg) is considered untrusted — it is repo-controlled and could have been committed by anyone. Env vars listed in an untrusted .pa-run.toml are only forwarded if the variable name is in sandbox.project_env_allowlist in your config file. If a blocked var is requested, pa run aborts with an explanation. Pass --allow-project-env to bypass the check for a single run.

A .pa-run.toml is trusted only when both conditions hold: it is above the VCS root (e.g. ~/dev/.pa-run.toml for a repo at ~/dev/myrepo/), and no directory on the path from the file to $HOME is world-writable. Either condition failing makes the config untrusted — a world-writable path component is flagged with a warning in the log even when the file is otherwise above the VCS root. Trusted configs forward env vars freely.

PA_RUN_OPTS environment variable:

Set PA_RUN_OPTS to inject options for every pa run call in the current shell without modifying the shell hook. Useful for one-off session flags or scripted pipelines:

PA_RUN_OPTS="--no-change" pipenv install          # audit mode for this install only
export PA_RUN_OPTS="--no-network"                 # all subsequent hook invocations go offline
export PA_RUN_OPTS="--flags python:ssh-keys"      # session-level flag without a .pa-run.toml

Supported tokens: --no-change / -n, --no-network, --flags CAPABILITY, --allow-external-lockfiles, --expose-ssh-keys.

PA_SANDBOX_ISOLATION environment variable: set to container to run pa fix trials without bubblewrap inside a container that is already isolated and disposable (where bwrap cannot create namespaces, such as AWS Fargate). Unset or bwrap keeps bubblewrap; any other value is refused. It is not a config key. See Container isolation for what it runs, refuses and requires.

Filesystem isolation:

The sandbox uses a layered mount strategy to prevent install-time scripts from reading credentials or secrets outside the project:

Layer What happens
/ read-only The entire filesystem is mounted read-only — install scripts cannot modify system files or other projects.
$HOME hidden A fresh empty tmpfs is overlaid on the home directory, hiding ~/.ssh, ~/.aws, ~/.gnupg, .env files in sibling projects, and any other secrets stored there.
Safe home paths re-exposed A curated allowlist of home subdirectories is re-mounted read-only inside the tmpfs so package managers can function normally (see table below).
Install targets writable The project directory, site-packages/node_modules/vendor, and package manager caches are bound writable on top of the above.

Paths re-exposed inside the home tmpfs (read-only):

Path Purpose
$PYENV_ROOT (~/.pyenv) pyenv-managed Python installations
$NVM_DIR (~/.nvm) nvm-managed Node.js installations
~/.local/bin User-local binaries (uv, pip-installed scripts, etc.)
~/.local/share/uv uv-managed Python installations and tool environments
$PIPX_HOME, ~/.local/pipx, ~/.local/share/pipx pipx-managed tool environments (shebangs in ~/.local/bin may point here; location varies by how pipx was installed)
~/.config/pip, ~/.pip pip configuration (index URLs, proxy settings)
~/.config/uv uv configuration
~/.npmrc (or the file npm_config_userconfig names) npm registry and auth configuration
~/.cache/pip, ~/.cache/uv, ~/.npm Package manager caches (writable)
~/.config/composer Composer home (writable, when present)

Paths that are not accessible inside the sandbox by default: ~/.ssh, ~/.aws, ~/.gnupg, ~/.config/gcloud, ~/.netrc, ~/.git-credentials, and everything else in $HOME not listed above. Pass --flags python:ssh-keys to re-expose ~/.ssh read-only when SSH-authenticated VCS dependencies are needed.

Environment isolation:

The sandbox process also starts with a stripped environment. A curated set of variables is forwarded (PATH, HOME, locale, proxy settings, registry URLs for pip/uv/npm/composer — for npm, npm_config_registry and npm_config_@scope:registry (only a URL without credentials) and npm_config_userconfig, in any letter case, and a variable that is the whole value of a registry line in your own .npmrc (registry=${CORP_REGISTRY}) and holds a URL without credentials, but not tokens or any variable substituted into part of a URL (https://${NPM_TOKEN}@host/), and never the variables a project's .npmrc refers to, since that file is the repository's (pass one with --env if the project needs it) — SSL certificates, pyenv/nvm locations). Variables not in this allowlist are removed. Use --env VAR on the command line or sandbox.extra_env in the config file to forward additional variables.

Requirements: bwrap (bubblewrap) must be installed.

# Ubuntu/Debian
sudo apt install bubblewrap

# Fedora/RHEL
sudo dnf install bubblewrap

# Arch
sudo pacman -S bubblewrap

Troubleshooting: bwrap: setting up uid map: Permission denied

This error means the kernel is blocking unprivileged user namespaces, which bwrap requires. It is most commonly seen on Ubuntu 24.04 bare metal, which enabled an AppArmor restriction by default that was later relaxed in 25.04+.

Check whether the restriction is active:

cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns

If the output is 1, disable the restriction:

sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

To make the change permanent across reboots:

echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-userns.conf
sudo sysctl -p /etc/sysctl.d/99-userns.conf

If you prefer a targeted fix that leaves the global restriction in place, you can instead grant bwrap permission via an AppArmor profile:

sudo tee /etc/apparmor.d/bwrap <<'EOF'
abi <abi/4.0>,
include <tunables/global>

profile bwrap /usr/bin/bwrap flags=(unconfined) {
  userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap

This allows bwrap to use user namespaces without disabling the restriction system-wide.

On other distributions (Debian, older kernels) the relevant setting may instead be:

sysctl kernel.unprivileged_userns_clone   # should be 1

Virtual environment detection: for Python commands, package-alert automatically detects the target site-packages directory by checking (in order) the executable path in the command, VIRTUAL_ENV (pip, uv pip install, and pipenv), and .venv/venv directories in the current working directory. Project-aware uv subcommands (uv add, uv remove, uv sync, uv lock) resolve the virtualenv from the project directory and do not consult VIRTUAL_ENV. External virtualenv managers are supported:

  • pyenv-virtualenv — if VIRTUAL_ENV points to a venv under $PYENV_ROOT/versions/, it is accepted rather than blocked.
  • pipenv / virtualenvwrapper — if VIRTUAL_ENV points to a venv under WORKON_HOME (default ~/.local/share/virtualenvs), it is accepted when PIPENV_VENV_IN_PROJECT is unset. If PIPENV_VENV_IN_PROJECT is set, the venv is expected inside the project tree and an external location is blocked as usual.

status

Show the current state of the daemon and related paths.

package-alert status [--json] [--config PATH]

Displays:

  • Daemon running/stopped, PID, uptime, and whether it was started by systemd
  • Config file path in use
  • Daemon log file path and whether it exists
  • CLI log file path and whether it exists

Use --json for machine-readable output.

scan-project

Scan a project directory for vulnerable or malicious packages.

package-alert scan-project [PATH] [OPTIONS]
Option Default Description
PATH . Project directory
--scan-unpinned off Also query OSV for unpinned/range-constrained dependencies
--scan-installed off Scan venv/.venv site-packages or node_modules instead of lock files
--prod-only off Exclude dev dependencies from the scan (lock file scanning only; mutually exclusive with --scan-installed)
--requirements / -r — Explicit requirements file to scan instead of auto-detecting lock files (mutually exclusive with --scan-installed)
--details / -d off Show full advisory details and URL; also reveals suppressed low-signal risk rows
--no-risk off Skip heuristic risk scoring (typosquat, popularity). Scoring is on by default
--no-yank off Skip checking locked versions for yanks
--format / -f text Output format: text, json, html, browser

Yanked versions. scan-project asks the registry whether each locked version has been yanked (withdrawn by its maintainer; PyPI only for now) and lists those under Yanked versions with the maintainer's reason, in text, JSON (yanked, yank_failures) and HTML. A yank is a warning, not a finding: maintainers yank for broken packaging as well as for problems that never got an advisory, so it does not change the exit status. Answers are cached for 24 hours; a lookup that fails is counted as unchecked, never as "not yanked". Packages locked from a private index, git or a local path are not checked: a public package of the same name is a different package. --scan-installed skips the check, since installed packages do not record which index they came from. --no-yank skips the check.

Risk scoring. In addition to OSV advisories, scan-project scores each queried package with the heuristic risk engine and prints a Risk signals section. Which signals can fire depends on the scan mode:

mode signals available
lock files (default), --requirements typosquat and popularity only — the packages are declared, not extracted, so there is no source to inspect
--scan-installed the above plus the full source-code heuristics: install scripts, eval, subprocess/socket in setup.py, embedded binaries

--scan-installed reads the real venv/site-packages or node_modules, so the extracted source is available and a malicious package is scored on what it actually contains. A package with a setup.py that shells out and opens a socket scores 80 (critical) in that mode versus 0 from its metadata alone — so prefer it when you want to know what is installed rather than what is declared.

Because source signals push scores much higher, the warning boundary for --scan-installed is sandbox.preflight_risk.post_install_threshold (default 30) rather than risk_threshold (25); see the level note below.

Scoring adds one deps.dev lookup per uncached package, bounded to 10 concurrent requests, with a progress bar. Per-package failures degrade to "no score" and are summarised at the end — risk scoring never fails a scan. Use --no-risk for offline or CI runs where the extra round-trips are unwanted.

The risk section prints one line per package by default, carrying the single most actionable reason — a typosquat match takes precedence over a low-popularity note, since naming the impersonated package is the point. --details expands the per-signal breakdown beneath each line, showing which named signals contributed and how much each scored (e.g. typosquat (15) + low_popularity (20) = 35).

Rows whose only signal is a minimal low_popularity hit (score 5) are hidden by default, since most small legitimate libraries trip it; they are still counted in the footer and shown with --details. This applies to the HTML report as well as the terminal output. JSON always contains every row and signal regardless of --details, since it is meant for machine consumption.

Formats:

  • text — colour-coded terminal output; severity badge on the advisory line ([HIGH] GHSA-…), plus a Risk signals section
  • json — machine-readable JSON with all findings, unpinned packages, sources, and a sibling risks array (per-package, one row each, with the full signal breakdown). findings remains advisory-shaped — one row per advisory — so existing consumers are unaffected

Each risk row carries a level of info, warning, or critical. These are calibrated to the thresholds that actually govern this surface, not to the daemon's. warning starts at sandbox.preflight_risk.risk_threshold (default 25) for lock-file scans, so anything that would gate pa run is visually distinct from informational noise; a metadata-only score of 35 is a warning here even though the daemon — which reaches much higher scores — would call 35 informational. Under --scan-installed the source-code signals put the range on a par with the post-install scan, so warning starts at post_install_threshold (default 30) instead. critical is heuristics.critical_threshold (default 70) in both modes.

  • html — self-contained HTML report printed to stdout
  • browser — writes HTML to /tmp/package-alert-*.html and opens it in the default browser

--requirements accepts a path relative to PATH (the project directory) or an absolute path. Nested -r/--requirement includes within the file are followed recursively. --requirements and --scan-installed are mutually exclusive.

--prod-only filters out dev dependencies before querying OSV. Dev/prod detection coverage by lock file format:

Lock file Dev detection
package-lock.json v2/v3 "dev": true flag per entry
package-lock.json v1 per-entry "dev": true flag (when present); falls back to devDependencies keys at root for direct deps
yarn.lock full graph traversal via dependencies: blocks in each entry, seeded from package.json; ⚠ packages not reachable from either seed (e.g. missing dependencies: entries) are undetectable
pnpm-lock.yaml full graph traversal via snapshots: section (lockfileVersion 9+), seeded from importers['.']; without snapshots: falls back to direct seed names only; ⚠ undetectable if no importers: section (older single-project lockfiles)
uv.lock full dependency graph traversal from root package dependencies/[package.dev-dependencies]; packages reachable from both trees are treated as prod
Pipfile.lock [develop] section
composer.lock packages-dev section
requirements.txt ⚠ not supported — a warning is printed and all packages are included

Auto-detected lock files (in order of precedence):

  • package-lock.json → npm
  • uv.lock → PyPI (uv)
  • Pipfile.lock → PyPI (pipenv)
  • requirements.txt / requirements/base.txt / requirements/prod.txt → PyPI (only when no uv/pipenv lock found)
  • composer.lock → Packagist
  • composer.json (fallback when no lock file) → Packagist

fix

Plan the commands that upgrade a uv or npm project's vulnerable packages. pa fix prints the commands and changes nothing in the project. To check them it runs commands in the sandbox — for uv, read-only ones in the project (uv lock --dry-run trial resolves, which use the network, and uv export --frozen --offline); for npm, npm install … --package-lock-only --ignore-scripts --no-audit --no-fund in a scratch copy of package.json, package-lock.json and .npmrc (see npm projects below) — and it queries OSV and the package registry.

package-alert fix [PATH] [--allow-major PKG|all] [--allow-cooldown] [--no-verify] [--allow-project-env] [--format text|json] [--config FILE]

While it works, pa fix shows a one-line progress indicator naming the current step (on stderr with --format json); nothing is shown when the output is not a terminal. Each vulnerable package gets one exact pin (uv lock --upgrade-package name==version). When PATH is not the current directory, both printed commands name it with uv's --directory, so they can be run from anywhere. The printed uv sync keeps what the project's .venv has installed: when its packages match the lock's default set plus some extras or groups, the matching --extra/--group flags are added. When they cannot be matched, pa fix warns that a plain uv sync would remove packages, and you add the flags you installed with. That pin is the lowest version that fixes all of its advisories, so the upgrade stays on the installed release line when that line has a fix. When no version fixes every advisory, the pin is the lowest version that leaves the fewest open, and the plan lists the rest under "still open after this" (left_open in JSON); such a plan exits 1, since applying it does not fully fix the package. Transitive packages show the chain that pulls them in. Major upgrades and versions still inside the cooldown period are held back unless --allow-major / --allow-cooldown is given. Malicious packages, packages with no known fix, unverified picks, non-registry sources and packages locked at several versions are always held back, with the reason. A package that is one of your own workspace members is held as workspace member, never upgraded. When the recommended version's age is unknown, the plan notes "age unknown — cooldown not checked".

Before printing, pa fix checks each pin by resolving it with uv lock --dry-run in the sandbox, using the project's .pa-run.toml flags, so private-index credentials work. A pin that uv cannot satisfy is held as blocked, naming the package that blocks it. A pin that would downgrade another package or add a package with known advisories is held, with what it would change. A pin whose trial would install a yanked version is held as would install a yanked version. Locked versions their registry has yanked are listed after the commands (checked against the registry, so this works with no fixes planned); they do not affect the exit status. When a blocking parent can itself be upgraded safely, both are planned. --no-verify skips the check. For npm, a transitive fix is then held as could not verify, since only a trial chooses how to apply it (an override or a parent upgrade); the same happens when trials cannot run. The plan is then marked not verified and exits 1, as it does when the sandbox is unavailable (bwrap missing) or the project's run config cannot be used or disables the network.

If a trial itself fails for a pin (for example the sandbox errors out), that pin is held as could not verify and the other pins are still checked. The hold names uv's error, such as a dependency that fails to build. When every trial fails with the same error, the plan says so once, since the cause is then the project or the machine (for example a package that only builds with a CUDA toolkit) rather than any one pin. could not verify also covers an inconclusive trial, an unavailable advisory lookup, and a trial that does not move the target to the planned version.

When each pin passes alone but the pins together do not (the combined trial finds no resolution, is inconclusive or errors, or fails one of the same checks: a downgrade, new advisories, a yanked or too-new version, a major upgrade, or a package left at several versions), the plan is in separate mode and says why (separate_reason in JSON): pa fix prints the lock command for the first package only, then a Next, after applying that… line listing the remaining fixes. Apply the printed commands, re-run pa fix, and repeat. Separate mode always exits 1.

pa fix --apply applies the verified plan instead of only printing it. At a terminal it first asks Apply N fixes? (--yes skips the question). It then re-runs the lock update in a sandboxed scratch copy (npm installs there are --package-lock-only --ignore-scripts), and writes the result only when the re-resolve leaves the same package versions verification judged, and only when the project's lock and manifest (pyproject.toml or package.json) are unchanged since pa fix started; the originals are saved under ~/.cache/package-alert/fix-backups/. Each file is swapped into place atomically (renameat2), so a concurrent change is never overwritten; on a filesystem without that support (some network and FUSE filesystems) --apply refuses and you run the printed commands. Finally, at a terminal (and outside CI), it syncs the environment (uv sync --locked / npm install) through pa run's sandbox and checks. Elsewhere, or with --lock-only, it updates only uv.lock (or package-lock.json and package.json) and never installs anything; --sync forces the sync. A uv project whose metadata is dynamic, or that uses a workspace or path sources inside the project, is copied from its git-tracked files. A path source or workspace member outside the project directory (../lib) cannot be held by that copy, so --apply refuses such a project, naming the source; run the printed commands there. One plan is applied per run: in separate mode, run pa fix --apply again for the rest. The exit status is 0 only when everything was planned, applied and (when syncing) synced.

Trials run with the sandbox's environment allowlist, so UV_* resolver settings in your shell (extra indexes, constraints, UV_EXCLUDE_NEWER, ...) are not applied during verification. Put them in the project config instead.

--allow-project-env bypasses the sandbox.project_env_allowlist check for env vars requested by an untrusted .pa-run.toml, for this run only (the same option as pa run).

Under the printed commands, an Also changes: line lists what those commands change beyond the fixes listed above (other packages upgraded, added or removed by the resolution), taken from the trial that matches the printed command: the combined trial, or the first package's own trial in separate mode.

With --format json, each planned item carries verified, changes (other packages the trial changed), parent (a blocking parent that is upgraded with it, or null) and the plan carries detail on held items, verified and unverified_reason (why no trial ran, else null), separate and deferred (the packages left for later rounds in separate mode). The plan also carries command_changes: the Also changes: list as {action, package, old, new} objects.

Exit status: 0 only for a verified, complete plan, with everything vulnerable planned and fully fixed; 1 when anything is held back, left open, unverified, in separate mode or could not be checked; 2 when there is no usable lock file (uv.lock and package-lock.json are supported), or the project holds lock files for more than one supported package manager.

--allow-major takes a package name and can be repeated or comma-separated: --allow-major cryptography --allow-major pip and --allow-major cryptography,pip allow major upgrades of those two packages only. --allow-major all allows every package. A bare --allow-major with no value is a usage error. Names are matched by the ecosystem's own rule: for Python case-insensitively, treating -, _ and . as the same; for npm case-insensitively only, and scoped names (--allow-major @babel/core) are accepted. For npm, a 0.x version's minor number counts as its major (0.3 → 0.4 is a major upgrade, as is 0.0.3 → 0.0.4).

Packages you always allow can be listed in the main config and in a trusted .pa-run.toml; these lists are unioned with the command-line value:

# config.toml
[fix]
allow_major = ["cryptography", "pip"]
# .pa-run.toml (honoured only when the file is trusted)
allow_major = ["cryptography"]

all is rejected in both files: allowing every package stays a per-run command-line choice. An untrusted .pa-run.toml's allow_major is ignored, with a warning.

For a held major upgrade, pa fix reads the package's release history from PyPI and labels packages for which a major bump is routine: — routine for cryptography (bumps its major version every release) or — routine for pip (uses calendar versioning). The hint then names the package: (use --allow-major cryptography). The label never changes whether a fix is held, and nothing is looked up when the project config disables the network; a failed lookup just leaves the label off. In JSON, each held item carries cadence: "every-release", "calendar" or null.

npm projects

A project with a package-lock.json (lockfileVersion 2 or 3, as written by npm 7 and later) is planned with the same policy as a uv project. npm workspaces, lockfileVersion 1 and npm-shrinkwrap.json are not supported (exit 2). npm has no lock-only pin, so every printed npm command edits package.json as well as the lock file:

  • A direct dependency is pinned with npm install name@version, which locks exactly that version and saves it to the section it is already in (dependencies or devDependencies) with your save prefix — ^version by default, so package.json allows later versions on the same major line while the lock file holds the one installed.
  • A transitive dependency is fixed by upgrading the package that keeps it back — the one whose declared range does not admit the target, which may itself be transitive (express pinning body-parser@1.20.1 under react-scripts) — when that upgrade passes every check. The upgrade is printed as npm install parent@version for a direct dependency, and as an overrides entry scoped to the parent's own major line (npm pkg set 'overrides[express@>3 <4.22.3]=4.22.3') for a transitive one, since installing it would make it a direct dependency; the same declared-range check applies to it. The upgrade may bring a newer release of the vulnerable package than the planned target; that is accepted when it is on the target's major line, fixes the package's advisories and is out of the cooldown period, and the plan shows the version it actually brings. Otherwise pa fix tries a forced pin, for a transitive package only (npm refuses an override on a direct dependency's name: EOVERRIDE ... conflicts with direct dependency), and only when every package that depends on a copy it would move declares a range that admits the target (checked with node-semver): forcing past a version a parent pins (react-router 6.16 pins @remix-run/router@1.9.0) resolves cleanly but can break that parent at run time, so such a fix is held as blocked, naming the parent to upgrade. A forced pin is: an overrides entry scoped from the major line of the lowest vulnerable copy up to the target, so copies already at or above the target and copies on older major lines are untouched (the trial is could not verify if the override moves one across a major line anyway). A forced fix is labelled pins <pkg> to <version> with a temporary override: npm would keep the locked <old>, though <range> under <parent> allows <version> (forced: {under, declares} in JSON), where <range> is what that parent declares for the package (or, when it does not declare it, the copy's direct parent). The range always admits the target: the override is needed only because npm install keeps a locked version its range still allows.
  • When the package keeping a vulnerable copy back cannot move far enough within its own declared range, pa fix looks up in the public npm registry the lowest later release of that package whose range admits the target (only for a package locked from the public registry; a private one gets no such lookup), and trials the two together, overriding the vulnerable package as well (npm would otherwise keep its old version wherever the new range still admits it). When more than one package pins it, each gets its own release, up to three. jspdf 2.5.2 is pinned by jspdf-autotable 3.8.4 and react-to-pdf 1.0.1 (both ^2.5.1); jspdf-autotable 5.0.7 and react-to-pdf 3.0.0 accept jspdf 4, so the fix prints npm install jspdf-autotable@5.0.7 react-to-pdf@3.0.0 plus an override of jspdf. Those releases are usually new major versions, which are held until you allow them, and the plan names them (use --allow-major jspdf-autotable,react-to-pdf). Test the project after applying such a fix: a new major version can change the package's API. Two fixes needing the same package share its highest release.
  • When the planned target is held (it adds an advisory, a downgrade or a deprecated version, say), pa fix retries with the newest version the package's dependents allow on the same major line (what npm update <pkg> picks), provided it still fixes the package's advisories and is out of the cooldown period. The planned target is the lowest fixed version; a newer release is sometimes the one that works (lodash 4.18.0, the fixed version, is deprecated as a bad release; 4.18.1 is not).
  • npm often locks a package at several versions (each nested under a different parent). A transitive package is planned once per major line, each with its own target and override window, so a line with a fix is not held back by an older line without one (which is held as a major upgrade on its own); lines whose fixes reach the same version, once that major upgrade is allowed, are one item again, and a direct dependency stays one item. Every vulnerable copy on the line must reach the target: a copy left below it holds the fix as blocked by the package keeping it there, which leads into the parent upgrade and then the forced pin.
  • npm has no yank, but a maintainer can deprecate a single release. A deprecated release is treated as yanked only when the newest release on its own major line is not deprecated (lodash 4.18.0, "Bad release", beside 4.18.1), since maintainers also deprecate whole major lines when they reach end of life (every eslint 8.x, glob 7.x, uuid ≤ 10): a fix that would install it is held as would install a yanked version, and a locked one is listed with the deprecation message. An end-of-life line, or a package deprecated as a whole, is not, and neither is anything locked from a private registry, git or a local path (the public package of that name is a different one): a package-lock.json or yarn.lock entry counts as public only when its resolved URL is on registry.npmjs.org (or registry.yarnpkg.com); an entry that records no URL (every pnpm-lock.yaml registry entry) follows the registry npm is configured to use for it, in npm's own order: the environment (npm_config_registry, npm_config_@scope:registry), then the project's .npmrc, then the user's (registry= or @scope:registry=; npm_config_userconfig moves the user's file), and a file:, link:, git or URL version, a workspace member or any other local directory (a lock entry outside node_modules/) is never public. The same rule keeps private packages out of package-alert run's publicly-looked-up risk and cooldown checks, before and after the command. A package whose package.json already has an overrides entry is held as could not verify rather than rewritten, and one installed under an alias ("alias": "npm:bar@^1") is held as installed under an alias: a command naming bar would add it as a new dependency instead of moving the alias.

The commands are one npm pkg set 'overrides[<name>@<range>]=<target>' per forced pin (for example npm pkg set 'overrides[semver@>6 <7.6.0]=7.6.0'), then one npm install naming the direct pins and parent upgrades (a plain npm install when there are none), which updates the lock file and node_modules. The overrides are temporary: they are then removed again (npm pkg delete 'overrides[<key>]', and the empty overrides object when the project had none) and the project re-locked with a second npm install. Every dependent's range admits each target, so npm keeps the versions the first install locked, and package.json ends as it was apart from any direct pin or parent upgrade; the trial runs the same sequence and judges the final lock, so a fix npm would undo without its override is held rather than printed. A direct dependency whose peer range a pin overrides (@vitest/ui 4.1.10 requires exactly vitest@4.1.10) is named in the same install at the version npm moves it to: npm moves it anyway, but warns ERESOLVE overriding peer dependency when it is not named. When PATH is not the current directory, each carries --prefix PATH. The key uses the bracket form (npm pkg set splits a dotted key) and contains no = (npm pkg set splits its argument at the first =, so >=7.0.0 cannot be written): its lower bound is a node-semver X-range, so >6 <7.6.0 means 7.0.0 up to but not including 7.6.0, and >0.2 starts at 0.3.0 (for a 0.x version the minor number is the major line, and for 0.0.x the patch).

A package-lock.json can be out of date (an unmet peer dependency, say), and npm then re-resolves part of it on any install. pa fix first re-locks a copy with nothing pinned and judges every trial against that result, so what npm changes anyway is not blamed on a fix; those changes are listed as lock drift, since the printed commands make them too, and a drift change that downgrades, adds an advisory or installs a deprecated release holds every fix that leaves it in place (one that moves the package off that version is not held for it). A fix that leaves a package on the version the project already has, instead of taking the re-lock's upgrade, is not a downgrade; it is held only when the kept version has an advisory the upgrade would fix (webpack's override keeping ajv 8.12.0, which re-locking alone moves to 8.20.0). A vulnerable package that re-locking alone moves to its fixed version is planned like any other fix, marked "re-locking the project moves it there": the printed npm install makes that change, so a transitive package needs no pin of its own. A direct dependency keeps its usual npm install name@version: that is the command its trial ran, and it raises the range package.json declares to the fixed version, so a later re-lock cannot go back below it.

Each trial copies package.json, package-lock.json and .npmrc into a fresh directory under ~/.cache/package-alert/fix-scratch/, runs npm there in the sandbox with that copy as the only writable directory, reads the resulting lock file (never npm's printed summary, which says up to date even after rewriting the lock) and removes the copy, including after a failure or timeout. A version that does not exist (ETARGET) or a peer-dependency conflict (ERESOLVE, naming the peer) is blocked; any other npm failure or a lock change pa fix cannot read is could not verify. Registry credentials reach the trial only through the Node plugin's usual sandbox flags; the project's own .npmrc is copied as is.

scan-cache

Scan pip and uv cache directories for wheels that have known malicious advisories.

package-alert scan-cache [--config PATH]

query

Query OSV for a specific package, with full advisory details.

package-alert query PACKAGE [VERSION] [--ecosystem ECOSYSTEM] [--config PATH]

--ecosystem accepts any OSV ecosystem identifier — e.g. pypi, npm, packagist, maven, crates.io, rubygems, nuget, go. Defaults to pypi.

alerts

Show recent alerts stored in the database.

package-alert alerts [--limit N] [--config PATH]

Displays a table with: package name, ecosystem, version, advisory ID or risk score, project path, and timestamp.

clear-cache

Clear the OSV query cache.

package-alert clear-cache [--ecosystem pypi|npm|packagist] [--config PATH]

Omit --ecosystem to clear all ecosystems.

languages

Inspect the loaded language modules.

# List all supported languages with their ecosystems and process names
package-alert languages list

# Show full details for a specific language
package-alert languages info python
package-alert languages info node
package-alert languages info php

languages info shows: ecosystems, process names, lockfile patterns, cache paths, and the top-packages URL used for typosquatting detection.

setup shell

Install shell function integration so pip, uv, npm, etc. are intercepted transparently.

# Print the shell snippet (source it manually)
package-alert setup shell

# Append an eval line to ~/.bashrc or ~/.zshrc automatically
package-alert setup shell --install

# Print just the eval line
package-alert setup shell --print-rc-line

Once installed, commands like pip install requests route through package-alert automatically — no package-alert run prefix needed.

setup project

Install project-local shims in .venv/bin/, venv/bin/, and node_modules/.bin/. Shims intercept direct binary invocations from coding agents and subprocesses that bypass the shell.

# Install shims in the current project
package-alert setup project

# Remove shims and restore original binaries
package-alert setup project --uninstall

# Also append PATH_add lines to .envrc (for direnv users)
package-alert setup project --envrc

cooldown allow

Pre-clear a package version to bypass the cooldown policy. Useful when an agent can't respond to interactive prompts.

package-alert cooldown allow requests 2.32.0
package-alert cooldown allow lodash 4.17.21 --ecosystem npm

Clearances expire after sandbox.cooldown.period_days (default 7 days) and are recorded only after a successful install.

The cooldown policy is applied twice by package-alert run: before the command, to the packages it names and the versions already locked, and after it, to the versions the command chose itself while it ran — what a re-resolving command (npm update, an install of an out-of-date lock, uv lock --upgrade) newly wrote to a lock file. A version refused after the run is rolled back with the lock files and install targets, exactly as a malicious package is. Interactively, every version inside the cooldown period is listed and one prompt answers for all of them; the ones you confirm are recorded as cleared once the run has succeeded (a run that fails or is rolled back records none). A publication date that a recent risk-scoring lookup failed to fetch is fetched again rather than treated as unknown.

Risk gating for run

[sandbox.preflight_risk] controls how package-alert run reacts to typosquat matches and high heuristic risk scores. It is independent of the cooldown policy: a typosquat match is caught whether or not the package is inside the cooldown window, and whether or not its ecosystem exposes a publication date.

The gate runs before the cooldown check, so you are never asked to answer a cooldown prompt about a package you are then told is a typosquat.

Why the thresholds differ from [heuristics]. At pre-flight nothing is installed, so only metadata signals can fire (typosquat, low popularity) and scores top out around 40. The daemon's warning_threshold of 40 would make the gate a no-op, so pre-flight has its own risk_threshold (default 25). post_install_threshold (default 30) applies after extraction, where install-script, eval, and embedded-binary signals are also available.

The post-install default is calibrated against the actual heuristic score tables rather than picked round: any single PyPI setup.py signal — subprocess_in_setup, network_in_setup, credential_in_setup — scores 30, and an npm postinstall hook piping curl into a shell scores 35 (install_script 20 + curl_in_script 15). Damping reduces these further for packages with real publication history, so a higher threshold silently misses genuine attacks.

Typosquat false positives. Pure edit distance flags many legitimate packages: httpx2 and httpcore2 are distance 1 from httpx/httpcore, and respx is distance 2 from regex. Two signals corroborate the match before it gates:

  1. Adoption of the suspect itself. A typosquat is by definition a new, unadopted package wearing a popular name. The risk engine scales the typosquat score down by the suspect's own dependent_count (from the deps.dev data it already fetches, plus one lookup of the installed version). deps.dev counts dependents per version, so the larger of the latest version's and the installed version's count is used: a package whose newest release is days old (preact 11.0.1, 72 dependents) is still credited with the adoption of the release being installed (preact 10.29.8, 31,775). httpx2 has tens of thousands of dependents; reqeusts does not exist on deps.dev at all. The reduction is driven by dependents rather than release count, because publishing many versions is cheap for an attacker — numpi has 36 releases and 6 dependents and earns no reduction. It is graded rather than a veto, and floored at 1, so a finding is never silenced and registry data gaps cannot create false negatives.

  2. Version-suffix variants — only in combination with adoption. A name differing from a popular package only by a trailing version digit (httpx2, psycopg2, jinja3 vs jinja2) follows a conventional release-line naming pattern rather than the character-level corruption attacks use. This deepens the reduction from signal 1, but never applies on its own: a version-suffixed name is equally consistent with a genuine successor release and with a brand-new squat, so a newly published requests2 or numpy2 keeps its full score and is gated exactly like reqeusts. Treating the suffix as exculpatory by itself would make appending a digit a one-character bypass of the whole gate.

    Longer conventional affixes (types-, python-, -async) need no handling at all: they are 3+ characters, so they exceed the distance threshold on their own and never produce a match.

Gating then keys on the resulting score (typosquat_min_score, default 15) rather than the bare match, so those reductions actually suppress the gate. Worked examples: httpx2 → 3 (allowed), respx → 14 (allowed), reqeusts → 15 (gated), urlib3 → 20 (gated). Because gating is score-based, typosquat_max_distance stays at the detector's own threshold of 2 and genuine distance-2 attacks (reqeusts, cryptografy) are still caught.

Weak matches are still reported by scan-project with the reduction explained in the signal reason — downgraded, not hidden.

Post-install enforcement. A newly installed package scoring at or above post_install_threshold is handled per on_post_install_risk, which takes the same four actions as the pre-flight gate:

action behaviour
allow No-op — nothing reported, install kept
warn (default) Reports the packages and their signals; install kept
prompt Reports, then asks whether to keep the packages; declining rolls the install back
block Reports and rolls the install back

A rollback uses the same snapshot restore as a malicious-advisory hit — the packages are already extracted at this point, so keeping or reverting them is a real choice. As at pre-flight, prompt escalates to non_interactive_escalation when stdin is not a TTY, so CI runs and coding agents never silently keep a package that tripped the threshold.

The sandboxed shell is gated too. package-alert run <shell> scores the project's lock file and applies the same policy as a direct install: the gate decision is the highest-ranked action across all scored packages, so one blocking dependency blocks the shell. False positives are handled by scoring rather than by declining to enforce — an ordinary lock file of legitimate libraries resolves to warnings and the shell starts. Use scan-project for the full breakdown.

Setting heuristics.enabled = false disables risk scoring everywhere, including this gate.

version

Print the installed version and exit.

package-alert version

config-show

Print the resolved configuration as JSON (useful for verifying config file is being read).

package-alert config-show [--config PATH]

update

Upgrade package-alert to the latest version. Requires a pipx install (the recommended install method); exits with an error if the tool was installed another way.

package-alert update

This is equivalent to running pipx upgrade package-alert directly.

If the version changes and the daemon is running, update will restart it automatically:

  • systemd-managed daemon — runs systemctl --user restart package-alert.
  • Standalone daemon — sends SIGTERM, waits up to 10 seconds for the process to stop, then re-spawns package-alert daemon in the background.

Scheduled Scans

Register projects for automatic daily or weekly scans run by the daemon. Each path can be registered for multiple scan types independently.

# Register the current project for daily lock-file scans
package-alert schedule add --daily

# Also register for weekly installed-packages scans (both coexist independently)
package-alert schedule add --weekly --installed

# Register a specific path
package-alert schedule add /path/to/project --daily

# List all registered projects (shows all path/scan_type pairs)
package-alert schedule list

# Remove only the installed-packages scan entry
package-alert schedule remove --installed

# Remove all scan entries for the current project
package-alert schedule remove

# List completed scans for the current project (newest first)
package-alert scans list
package-alert scans list /path/to/project --limit 10

# Show findings from a specific scan
package-alert scans show 42
package-alert scans show 42 --format json
package-alert scans show 42 --format html
package-alert scans show 42 --format browser
package-alert scans show 42 --details

PA Central (Fleet Integration)

PA Central is an optional built-in plugin that connects a local package-alert agent to a central fleet server. When enabled:

  • The daemon sends periodic heartbeats to the server so you can see which agents are alive.
  • The daemon fetches a config overlay from the server (TOML) and applies it on top of the local config. This lets a fleet admin push heuristic thresholds, cooldown policy, or allowlists to all agents without touching individual machines.
  • The daemon reports alerts and scan findings to the server.
  • The daemon syncs cooldown clearances pushed by the fleet admin.
  • pa scans list/listall/show fetch results from the fleet server instead of local SQLite, so you can see scans from all enrolled hosts.

Enabling PA Central

# Enable the plugin
pa central enable

# Set your API key and server URL
pa central configure --api-key sk-abc123 --server-url https://fleet.example.com

# If the daemon is already running, these commands restart it automatically.

If your fleet server uses HTTP (not HTTPS), add allow_http = true to [plugins.pa-central] in your config file:

[plugins.pa-central]
allow_http = true

Disabling PA Central

pa central disable

This removes the plugin from plugins.enabled and deletes the locally cached config overlay.

Configuration

[plugins]
enabled = ["pa-central"]

[plugins.pa-central]
server_url = "https://fleet.example.com"   # fleet server base URL
api_key = "sk-abc123"                      # agent API key
# heartbeat_interval_seconds = 300         # how often to send heartbeats
# config_fetch_interval_seconds = 3600     # how often to pull the config overlay
# allow_http = false                       # set true to allow non-TLS server URLs

The api_key and server_url can never be overridden by the fleet server's config overlay — they are stripped before the overlay is applied.

pa central commands

Command Description
pa central list List all installed plugins and whether they are enabled
pa central enable [PLUGIN] Enable a plugin (default: pa-central)
pa central disable [PLUGIN] Disable a plugin
pa central configure [PLUGIN] --KEY VALUE … Set plugin config values
pa central status [PLUGIN] Show plugin status, config, and last heartbeat state

pa status — Central section

When PA Central is enabled, pa status shows a Central section:

Central
  Server:       https://fleet.example.com
  Heartbeat:    2026-06-11 14:22:01  ok
  Config sync:  2026-06-11 14:20:00  ok

Timestamps are shown in local time. If a heartbeat or config fetch has not succeeded, the last error message is shown.

pa scans with PA Central

When PA Central is enabled and configured (server URL and API key set), scan results are stored on the fleet server rather than in local SQLite. The pa scans list, pa scans listall, and pa scans show commands automatically query the fleet server. If the plugin is enabled but not yet configured, scan results continue to be stored locally until credentials are provided.

# Scans for the current project on this host
pa scans list

# All scans for this host across all projects
pa scans listall

# Show findings from a specific scan
pa scans show 42
pa scans show 42 --format json
pa scans show 42 --details

Config override policy

PA Central sets refuses_config_override = True, which means the --config flag is blocked whenever PA Central is enabled. This prevents a user from bypassing fleet-managed settings by pointing to a custom config file.

Integrating with a different backend

PA Central is built on a documented AgentPlugin interface. If you want to integrate package-alert with your own SIEM, alerting pipeline, or scan store instead of PA Central, see CENTRAL.md.


Configuration

Config is loaded from ~/.config/package-alert/config.toml automatically if it exists. Override with --config PATH on any command.

# Logging for the long-running daemon process.
[log]
level = "INFO"                                      # DEBUG, INFO, WARNING, ERROR, CRITICAL
file = "~/.local/share/package-alert/daemon.log"    # set file = "" to disable file logging
# max_bytes = 10485760    # 10 MB per file before rotation
# backup_count = 3

# Logging for short-lived CLI commands (scan-project, query, alerts, etc.).
[cli_log]
level = "INFO"
file = "~/.local/share/package-alert/cli.log"       # set file = "" to disable file logging

[watch]
enable_cache_monitoring = true
enable_process_monitoring = true
# Cache paths (pip, uv, npm, composer, etc.) are discovered automatically
# from each language module — no manual configuration needed.
site_packages_dirs = []                             # extra site-packages to watch
process_poll_interval_seconds = 1.0

[osv]
base_url = "https://api.osv.dev/v1"
cache_ttl_hours = 24
timeout_seconds = 10.0
max_retries = 3

[alerts]
desktop_notifications = true
terminal_notifications = true
min_severity_for_desktop = "MEDIUM"

[heuristics]
enabled = true
warning_threshold = 40
critical_threshold = 70
# top_packages_refresh_days = 7   # how often to refresh top-packages lists from each registry (default: 7 days)

# Risk score dampening — reduces false positives for well-established packages.
# high_dependent_count = 1000     # dependents at which popularity factor reaches its floor
# high_version_count = 50         # version count proxy when dependent_count is unavailable
# popularity_floor = 0.25         # minimum popularity multiplier (0.0–1.0)
# popularity_failure_ttl_minutes = 60
# max_damping_age_days = 90       # age in days at which age factor reaches its floor
# age_floor = 0.25                # minimum age multiplier (0.0–1.0)
# age_failure_ttl_minutes = 60
# combined_damping_floor = 0.1    # floor for popularity_factor × age_factor

[sandbox]
# Additional environment variable names to forward into the sandbox beyond
# the built-in allowlist (PATH, HOME, proxy vars, registry URLs, etc.).
extra_env = []
# Example: extra_env = ["MY_PRIVATE_REGISTRY_TOKEN", "CUSTOM_CERT_PATH"]

# project_env_allowlist = []  # exact env var names that untrusted .pa-run.toml files
                               # (at or below a VCS root) may forward into the sandbox.
                               # Trusted .pa-run.toml files (above the VCS root, no
                               # world-writable path components) are not restricted.
                               # Example: project_env_allowlist = ["MY_TOKEN", "REGISTRY"]

# Additional paths to mount as empty tmpfs inside the sandbox.
# Use this on systems where other root-owned paths cause tool failures inside
# bwrap's user namespace (e.g. SSH proxy config files owned by root).
extra_tmpfs = []
# Example: extra_tmpfs = ["/etc/ssh/other_config.d"]

# Additional paths inside $HOME to re-expose read-only inside the sandbox.
# Use this when a tool is installed as an editable/development install whose
# source directory lives inside $HOME (which is hidden by default).
extra_ro_paths = []
# Example: extra_ro_paths = ["/home/user/dev/my-tool"]

# Directory trees from which pip install -e of *external* sources is permitted.
# In-project editable installs (e.g. pip install -e .) always work because the
# project directory is already writable in the sandbox. This setting controls
# editable installs from source directories *outside* the project root (e.g.
# pip install -e ../../other-lib). When empty, external editable installs are
# blocked. System directories and credential directories (~/.ssh, ~/.aws, etc.)
# are always blocked regardless of this setting.
editable_roots = []
# Example: editable_roots = ["~/dev", "~/projects"]

[sandbox.cooldown]
# Packages published more recently than period_days trigger the cooldown policy.
period_days = 7

# Action when a package is within the cooldown period and has a non-zero
# heuristic risk score. One of: "prompt", "warn", "block", "allow"
on_new_medium_risk = "prompt"

# Action when a package is within the cooldown period and has no risk signals.
on_new_low_risk = "warn"

# In non-interactive contexts (no TTY — coding agents, CI), escalate "prompt" to this.
non_interactive_escalation = "block"

[sandbox.preflight_risk]
enabled = true

# Score at or above which on_high_risk fires (pre-flight, metadata-only).
risk_threshold = 25

# Action when the package name looks like a typosquat of a popular package.
on_typosquat = "prompt"

# Only matches at or below this edit distance trigger on_typosquat; more distant
# matches are reported as warnings instead.
typosquat_max_distance = 2

# Minimum typosquat score required to trigger on_typosquat.
typosquat_min_score = 15

# Action when the pre-flight risk score reaches risk_threshold.
on_high_risk = "warn"

# In non-interactive contexts, escalate "prompt" to this.
non_interactive_escalation = "block"

# Score at or above which on_post_install_risk fires, after extraction.
post_install_threshold = 30

# Action when a newly installed package reaches post_install_threshold.
# "block" always rolls the install back via the snapshot restore; "prompt" asks and
# rolls back only if you decline.
on_post_install_risk = "warn"

[scheduler]
enabled = true
daily_hour = 2          # hour of day (0–23) to run daily scans
weekly_day = 6          # day of week to run weekly scans (0=Mon … 6=Sun)
weekly_hour = 2         # hour of day to run weekly scans
max_scan_history = 5    # completed scan records to keep per project per scan type

Scan types:

  • project (default) — scans lock files (requirements.txt, uv.lock, package-lock.json, composer.lock). Reproducible; works offline with cached OSV results.
  • installed — enumerates packages actually installed in the project's virtual environment (pip list, npm ls, composer show). Catches drift between lock file and real environment.

Data Storage

All persistent data lives in ~/.local/share/package-alert/:

File Purpose
package-alert.db SQLite database: OSV cache, alert history, popularity cache, top-packages cache, publication date cache, cooldown clearances
daemon.log Rotating daemon log file (10 MB × 3 backups)
cli.log Rotating CLI command log file (10 MB × 3 backups)
daemon.pid PID file used to prevent duplicate daemon instances

systemd (Linux)

package-alert daemon-install

This:

  1. Writes a default config to ~/.config/package-alert/config.toml if one doesn't already exist
  2. Writes the unit file to ~/.config/systemd/user/package-alert.service
  3. Enables and starts the service immediately

The daemon will start automatically on future logins. Edit ~/.config/package-alert/config.toml to customise behaviour, then restart with systemctl --user restart package-alert.

To stop and remove the service:

package-alert daemon-remove

Language Support & Plugins

See LANGUAGES.md for the full language module contract, how to write an external language plugin, and the incomplete Rust/Cargo example plugin.

See CENTRAL.md for the AgentPlugin interface — how to write a plugin that integrates package-alert with an alternative backend, how to register it via entry points, and the security invariants the runtime enforces.

Architecture

See ARCHITECTURE.md.

Sandbox

See SANDBOX.md.

Security

See THREAT_MODEL.md.

Roadmap

See ROADMAP.md.

Metadata

Release files for package-alert 0.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for package-alert 0.10.0
File Size Uploaded
package_alert-0.10.0.tar.gz 1.5 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for package-alert 0.10.0
File Interpreter ABI Platform
package_alert-0.10.0-py3-none-any.whl Python 3 none any Details

Total release size: 2.0 MB

Release files / package_alert-0.10.0.tar.gz

Download URL package_alert-0.10.0.tar.gz
Size 1.5 MB
Tags Source
SHA-256 checksum
How to use checksums
a030789086490d85e7f0fa65eb0a241a2b461ce45200cc9c5f68baf2069af75a
BLAKE2b-256 checksum
How to use checksums
5f893ac037fb53a47e9a20c0682ef30f6fdede3b1724e3cd38939989cf1d63bd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / package_alert-0.10.0-py3-none-any.whl

Download URL package_alert-0.10.0-py3-none-any.whl
Size 594.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0e105f9db1886af5905937238de7f7e22d3b45848c51eb52ee2854bea13a4522
BLAKE2b-256 checksum
How to use checksums
4f679dc91662111ec70c3fafc699e9ffea5668864ed29af7a90993cacff54963
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.10.0 This release

2 release files

0.9.2

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.2

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.6

2 release files

0.6.5

2 release files

0.6.4

2 release files

0.6.3

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page