Guarding installs
The configuration apply writes is what really protects a project: the package managers themselves wait out the release-age delay and refuse install scripts and sources the policy denies. depcurfew check adds a check before something is installed, which matters most for AI coding agents that add packages on their own:
npx depcurfew check zod@4 left-pad
npx depcurfew check pypi:requests "pypi:django>=5.2,<6"
npx depcurfew check composer:monolog/monolog "composer:symfony/console:^7.4"
For each package it looks up the npm registry (the one --registry or registry= in .npmrc names, else the public one; any other is named in a note), PyPI for names with the pypi: prefix, or Packagist for names with the composer: prefix, and reports:
- a name the registry does not know, which is how made-up package names show up;
- a version younger than the cooldown in
depcurfew.json, with the newest version that is old enough; - install scripts the policy does not allow; for a Python package, a version without wheels, which is built from source and so runs its build code; for a Composer package, a plugin (type
composer-pluginor the legacycomposer-installer, in any case), whose code runs during Composer commands. A package ininstallScripts.denyis not reported, because its scripts are meant to stay blocked (doctorchecks that the package manager blocks them), except a Python package without wheels, which gets a warning: it cannot be installed where building is blocked; - a name that differs by a letter or two, or only in separators, from a popular package of its registry: 5,000 npm packages with the most weekly downloads among the results of several hundred registry searches (the npm registry has no download ranking, so a popular package can be missing), and the 5,000 most downloaded PyPI projects of a recent month (there is no such list for Packagist);
- a deprecated version, or an abandoned Composer package (a warning);
- a registry or index reached over plain HTTP (a warning), unless it is on this computer (
localhost,127.0.0.0/8or::1), such as a local registry for testing; - a version OSV.dev lists as malicious (
known-malicious, an error), with the local data ofdepcurfew osv updateor with--osv, as described below.
A registry that redirects to another origin (another host, port or scheme, so also from http:// to https:// on the same host), and one at a link-local address, where cloud metadata services answer, is not followed or asked: the package counts as not checked, and so does a package whose registry document is larger than 256 MiB (renovate's, the largest, was 74 MB in November 2026; depcurfew reads it as it comes and keeps only the dist-tags, the publish times and the install scripts and deprecation of each version) or lists more than 100,000 versions (or a PyPI page with more than 100,000 versions or 200,000 files, or a Packagist answer larger than 16 MiB, with more than 10,000 versions or with a version longer than 255 characters), far more than any real package, because such a document is made to slow checks down. For a name without a version or with a range, npm prefers the dist-tag tag= in .npmrc names, and so does check.
.npmrc is read where npm reads it: ~/.npmrc, and the .npmrc of npm's local prefix, the nearest directory from the checked one up with a package.json or a node_modules directory (or the workspace root that lists it). Its registries and dist-tag count only when that directory is the one with depcurfew.json or a directory in its projects (without a depcurfew.json, the checked directory): an .npmrc anywhere else could name a registry that vouches for anything. When such an .npmrc sets registry, @scope:registry or tag to another registry or dist-tag than the one depcurfew used, the packages it applies to are not checked (an error that says which file and line, what it sets and which registry depcurfew checked), and a package of that scope is not looked up at all. A value that names the registry depcurfew checked (scheme and host in any case, without credentials and trailing slashes) changes nothing, and one depcurfew cannot read, such as a variable, counts as another registry. If you trust that registry, the fix says how to have depcurfew check it: --registry <URL> for check, in the hook command in .claude/settings.json and for depcurfew mcp, for a scope too. A scope that the .npmrc depcurfew takes (~/.npmrc or the one next to depcurfew.json) assigns to another registry is looked up only when --registry names that registry; otherwise the package is not looked up and is unverified, with the file and line of @scope:registry= and the registry depcurfew asks. A configuration file that is not a regular file or is larger than 1 MiB, such as a named pipe or a device in place of ~/.npmrc, is skipped with a note. So are the workspaces of a package.json with more than 1000 patterns, a pattern longer than 1000 characters or patterns that take too long to match: the nearer directory stays npm's local prefix, and a registry the .npmrc next to that package.json sets counts as one depcurfew did not check. npm skips the .npmrc of a workspace package, so a registry it sets counts only where npm may read it: when depcurfew cannot be sure that npm takes the directory for a workspace (a pattern with ?, [, braces or a trailing /, a negated glob, a directory whose name starts with a dot, a root package.json npm cannot read), for an npm command line that may turn workspaces off (--no-workspaces, --workspaces=false), and for pnpm and Bun, which read .npmrc by rules of their own. When the registry depcurfew checked has no such package or version while such an .npmrc names another registry, the package is unverified (with the file and line), not reported as a made-up name.
The registry and index settings of the machine count the same way, as each tool reads them, in the environment check, the hook and depcurfew mcp run in (for the hook, the one Claude Code gives it): for npm and the tools that read .npmrc, npm_config_registry, npm_config_@scope:registry and npm_config_tag in any case (they win over every file), the file npm_config_userconfig names, which npm reads in place of ~/.npmrc, and the global npmrc (the file npm_config_globalconfig names, else etc/npmrc below npm's prefix: PREFIX, else the directory above the Node.js that runs depcurfew), whose settings count where no file npm reads first sets the same key; for pip and a pypi: package that no command names a tool for, PIP_INDEX_URL, PIP_EXTRA_INDEX_URL and PIP_FIND_LINKS and index-url, extra-index-url and find-links in the [global] and [install] sections of the user pip.conf (also ~/.pip/pip.conf), or of the file PIP_CONFIG_FILE names in its place; for uv, UV_DEFAULT_INDEX, UV_INDEX_URL, UV_INDEX, UV_EXTRA_INDEX_URL, UV_FIND_LINKS and the user uv.toml, or the file UV_CONFIG_FILE names in its place; and the registry variables of pnpm, Yarn, Bun, PDM and Pipenv (pnpm_config_registry, YARN_NPM_REGISTRY_SERVER, BUN_CONFIG_REGISTRY, PDM_PYPI_URL, PIPENV_PYPI_MIRROR). When one points the install at another registry or index than the one depcurfew asks, the package is unverified, with the variable or the file and line, and it is not looked up at all, so that the name of a private package does not reach a registry the install does not use; the hook asks instead of denying it as a made-up name. Pass that registry with --registry (--index) when you trust it; a setting that only adds a place to install from (PIP_EXTRA_INDEX_URL, UV_INDEX) leaves the lookup, as the install asks the default index too, and always counts. Under npx and npm run, npm exports the settings of its .npmrc files as npm_config_* variables: those of ~/.npmrc and of the .npmrc next to depcurfew.json name the registries depcurfew takes from the same files, so they change nothing, and one from an .npmrc it does not take is named by its variable.
Python packages are written as pip takes them after the prefix (pypi:name, pypi:name==1.2, pypi:name[extra]>=1.2,<2), or as pypi:name@1.2, pypi:name@^1.2 and pypi:name@latest, as uvx and poetry add take them. The version is picked as pip picks it: the newest match, pre-releases only when asked for or when nothing else matches, and yanked versions only when pinned with == (a warning). A version is as old as its newest file, because files can be added to a release later. A project PyPI quarantines (as malware, for example) is an error; an archived or deprecated one, a warning. --index looks Python packages up in another index that serves the JSON form of the simple repository API.
Composer packages are written as composer require takes them after the prefix: composer:vendor/name, or the name and a constraint separated by :, = or a space (composer:monolog/monolog:^3.9, "composer:symfony/console >=7.4 <8"), with every form of Composer's constraints (exact, ranges, -, *, ~, ^, ||, , and stability flags such as @beta). Packagist is the only repository depcurfew asks: Composer's default, https://repo.packagist.org, and its v2 metadata of tagged releases. The version is picked as Composer picks it: the highest release the constraint allows at the minimum-stability of the root composer.json (stable without it), or at the stability the constraint names (@beta, 2.0.0-RC1), and the most stable of them first when prefer-stable is true; for a package named without a constraint, the version composer require picks itself. Messages write a version as composer require takes it: composer:monolog/monolog:3.12.1. A release is as old as its published-time, when Packagist published it, which the author cannot set, as Composer 2.11 counts its cooldown; a release without one is not checked. A constraint that names a branch (dev-main, 1.x-dev), or allows dev versions (@dev, or any constraint under "minimum-stability": "dev" without prefer-stable), is not vouched for: a branch is a commit, not a release, and Composer's cooldown never holds it back. Platform packages (php, ext-json) are not looked up, because Composer does not install them. When the root composer.json (of the checked directory, or the nearest one above it up to the directory of depcurfew.json) declares a repository or turns packagist.org off, a Composer package counts as not checked (an error that names the file, the line and the repository, and with which the hook asks), because Composer asks those repositories before Packagist and the first one that has the package wins; it is then not looked up on Packagist at all, so that the name of a private package does not leave the machine. The same holds for a root composer.json that depcurfew does not read (larger than 1 MiB, or not a regular file), which Composer reads with whatever repositories it declares. Composer uses a composer.json above the directory it runs in only when it runs in a terminal and you agree, or with use-parent-dir set to true, and never with --working-dir; otherwise it stays where it is, at the stability stable. So the version of a package is checked at the minimum-stability of such a file and at stable, and the stricter answer counts; its repositories count as above.
Git repositories, tarball URLs and local paths are checked against the sources section only. pypi:. and pypi:.[extras] are the project itself (a warning, not looked up) when the checked directory is the directory of depcurfew.json or one of its projects (without depcurfew.json, the checked directory); in any other directory they are a local directory. Without a depcurfew.json in the directory or one of its parents up to the root of the repository, the default policy applies. When the registry cannot be reached, the package counts as failed: check exits with 1 whenever a package breaks the policy or cannot be checked. With --json it prints one document and nothing on stderr: packages with their findings (each with code, level, message and fix, and for known-malicious source: osv-api or osv-local), notices (each with level note, a code, location when it concerns a file, message, and fix when there is something to do; the codes are policy-missing, nearer-policy, registry-not-public, registry-link-local, npm-dist-tag, config-not-read, and for the local OSV.dev data osv-data-old (level warning once the data is older than 30 days), osv-data-unreadable and osv-cache-shared (level warning: other users can write a directory of the data), and too-many-packages (level warning: more than 10,000 distinct specs when nothing else is checked, of which only the first 10,000 were looked up in the local data); without --json each is a note: or warning: line on stderr ending with the code, and its fix: line), and errors for a depcurfew.json that cannot be used, in which case no package is checked except the exact versions that the local OSV.dev data lists (see Known malicious versions).
Known malicious versions (OSV)
depcurfew knows malicious versions from OSV.dev, the open database of vulnerabilities and malicious packages of the Google project google/osv.dev, in two ways:
- A local copy, offline.
depcurfew osv updatedownloads the public export of OSV.dev for npm, PyPI and Packagist and keeps the records of the OpenSSF malicious-packages database in depcurfew's user cache directory. From then oncheck, the Claude Code hook,check_packagesofdepcurfew mcpanddoctorlook every version they check up in it, without--osvand without network access. Run it again to update the copy, for example once a day. --osv, live.--osvasks the API of OSV.dev about the versions depcurfew checks, which sends their names: it is off unless you pass it, on each command.
npx depcurfew osv update
npx depcurfew check zod@4 pypi:requests
npx depcurfew check --osv zod@4 pypi:requests
npx depcurfew doctor --osv
depcurfew osv update [--ecosystem npm|pypi|packagist ...] [--accept-fewer] [--json]:
- downloads
https://storage.googleapis.com/osv-vulnerabilities/<ecosystem>/all.zipfornpm,PyPIandPackagist(210 MiB, 36 MiB and 11 MiB in November 2026;--ecosystempicks some), keeps the records whose id starts withMAL-and that are not withdrawn (222,667 for npm, 11,951 for PyPI and 1 for Packagist then, after 334 and 25 withdrawn ones), and writes one file per ecosystem,npm.jsonl,pypi.jsonlandpackagist.jsonl, with each package name (PyPI names after PEP 503, Packagist names in lower case), the id of each record and the versions and ranges it affects, the time of the download and the date of the export (itsLast-Modified; a date after the download is not taken, and the age then counts from the download). The npm file is 17 MB; a lookup reads only the lines it needs; - keeps at most what the real exports need many times over: per record 1,000
affected[]entries, and per package 10,000 versions, 100 ranges of at most 100 events each; per package 1,000 records and a line of 1 MiB in the data file; and a data file of 128 MiB (in November 2026 the largest were 1 entry, 232 versions, 2 ranges of 4 events, 2 records and a line of 3,320 bytes). A record past one of these limits is left out whole, never in part, and counted astooLarge(in the text line, "larger than depcurfew keeps"); data larger than 128 MiB is not written (osv-export-unreadable), and the old data stays.check, the hook,mcpanddoctordo not read a data file larger than 128 MiB or a package line longer than 1 MiB: the data then cannot be read (osv-data-unreadable); - keeps the old data when the new export has fewer than half of the records of the data it would replace, a sign of a broken or cut export: the error
osv-export-shrunknames both counts, and--accept-fewerreplaces the data anyway, for when OSV.dev has really withdrawn that many records. An ecosystem without data, or with data that cannot be read, takes any count; - writes to
$XDG_CACHE_HOME/depcurfew/osvwhenXDG_CACHE_HOMEis an absolute path, else~/.cache/depcurfew/osvon Linux,~/Library/Caches/depcurfew/osvon macOS and%LOCALAPPDATA%\depcurfew\osvon Windows: the user cache directory as Python'splatformdirsplaces it. On Linux and macOS, when that directory,depcurfewin it ordepcurfew/osvcan be written by other users (by every user, as/tmpcan, by a group other than the user's own primary group, or because it belongs to another user than the user and root),osv updategives the warningosv-cache-sharedand still writes the data, and the lookups say the same: other users could change the data so that a malicious version passes. The fix ischmod go-won a directory of your own, or anXDG_CACHE_HOMEthat only you can write, anddepcurfew osv updateagain. The directoriesosv updatecreates are not writable by others, whatever the umask; - reads the zip file itself, without a zip library: it inflates only the
MAL-*.jsonentries into memory, never writes them to disk, and refuses an export larger than 1 GiB, more than 2,000,000 entries, an entry larger than 32 MiB or one that inflates more than 100 times (a zip bomb), encrypted entries, other compression methods than stored and deflated, entries that overlap or whose CRC-32 does not match, and a malformed central directory; it reads the ZIP64 end of the central directory, which the npm export needs for its 231,076 entries; - downloads to a temporary file next to the data (
.<ecosystem>.<process id>.<12 hexadecimal digits>.zip.part, and.jsonl.partfor the new data) and replaces the old file with one rename, so a failed, refused or partial download keeps the old data; it removes temporary files of exactly these names that a run left more than a day ago, and no other file. Each failure is an error with a code (osv-download-failed,osv-export-unreadable,osv-export-shrunk,osv-data-not-written,osv-cache-missing) and a fix, and the exit code is 1; the warningosv-cache-shareddoes not change the exit code. With--jsonit printsschemaVersion,directory,ecosystems(each withfile,url,downloadedAt,exportedAt,records,withdrawn,skipped,tooLargeandbytes),errors(each withcode,level,location,ecosystem,messageandfix) andwarnings(each withcode,level,location,messageandfix).
The local data and --osv report the same way:
- A version matches a record as the OSV schema evaluates
affected[]: listed inversions, or within aSEMVERorECOSYSTEMrange ofintroduced,fixed,last_affectedandlimitevents, compared as semantic versions for npm, as PEP 440 versions for Python and with PHP'sversion_comparefor Composer, wherev8.1.9and8.1.9are the same version, as in OSV.dev. Withdrawn records are never used. - In the JSON of
check,check_packagesanddoctor(and the properties of a SARIF result),sourcesays where aknown-maliciousfinding comes from:osv-localorosv-api. - Local data older than 7 days gives a note (
osv-data-oldincheckandcheck_packages, a line of the context of the hook for the agent,known-maliciousper lockfile indoctor), older than 30 days a warning, each with the fixdepcurfew osv update; the age counts from the date of the export. Data that cannot be read gives a note (osv-data-unreadable), and nothing is looked up in it; its reason names no text of the file. Data in a directory that other users can write gives a warning (osv-cache-sharedincheckandcheck_packages, a line of the context of the hook, aknown-maliciouswarning per lockfile indoctor), and the versions are still looked up in it: without it nothing would be found at all. Indoctor, a lockfile of an ecosystem without local data gets a note, and without any local data and without--osvone note namesdepcurfew osv update; otherwise nothing changes without the data. - With
--osvthe answer of the API counts, as it is fresher; the local data answers only for a version the API did not answer for, and only when it lists that version as malicious. A version it does not list staysunverified, since--osvasked for the live answer.
A listed exact version is denied before anything else is checked. check, the Claude Code hook and check_packages first look up, in the local data and without the network, every spec that names an exact version: name@1.0.1 (also name@=1.0.1, name@v1.0.1 and behind an npm: alias), pypi:name==1.0.0 and pypi:name===1.0.0, and a Composer constraint that is one version, with or without v (composer:vendor/name:5.0.2, :v5.0.2, :5.0.2.0). A version the data lists is known-malicious (source: osv-local) whatever would otherwise answer first, and the hook denies the command instead of asking: a depcurfew.json that cannot be used, an option that points the install at another registry or index (--registry, --index-url, --extra-index-url, --index, --repository and the others), more than 50 packages in one command or call, a registry that does not answer or has no such package, an install in more than 20 directories, or a command the hook asks about for another reason. The deny reason names the record first and then why the rest of the command was not checked. This holds with --osv too, without asking OSV.dev first. check then reports the listed versions next to the error that kept it from checking the rest and exits with 1 (2 for a directory that does not exist, which stays a usage error); check_packages with more than 50 specs returns them with the warning too-many-packages in notices. In a command that a word the shell expands leaves unreadable (npm i name@1.0.1 "$X", *, {a,b}, $(…), pip install name==1.0.0 $X, composer require vendor/name:5.0.2 $X), the specs written out are looked up too, and a listed one is denied with the expansion as the reason after it. One command, call or check looks up at most 10,000 distinct specs; a command with more names more than 50 packages, so the hook asks about it anyway, and every answer of the hook, check when it checks nothing else (a depcurfew.json it cannot use, a directory that does not exist; the warning too-many-packages) and check_packages say that only the first 10,000 could be looked up. A range, a dist-tag or a branch needs the registry to pick a version, which is then looked up as before; a version the data does not list gets the same answer as before.
check, the Claude Code hook (check --hook claude-code --osv) andcheck_packagesofdepcurfew mcp --osvlook up the version the install would pick, after the dist-tag, the range and the pre-release rules have chosen it, in one batch query per command. A version with a record of the OpenSSF malicious-packages database (an id starting withMAL-) is an error with the codeknown-malicious: it names the record and links its page, such ashttps://osv.dev/vulnerability/MAL-2026-18458, and the hook denies the command. Other OSV.dev records are vulnerabilities, not malware, and are not reported. For the hook, add"--osv"after"claude-code"in theargsof the hook command in.claude/settings.json(see below); fordepcurfew mcp, after"mcp".- When OSV.dev cannot be reached (no network, an error, a redirect, an answer too large or not in the documented shape, such as a record id outside the characters of the OSV schema or one result with more records than two of its pages), the package is
unverified:checkexits with 1 and the hook asks. No other text of an answer reaches a message: a redirect is named by the origin it points to, or as a location that is not a URL. A package is never passed because OSV.dev did not answer. doctor --osv(and the Action withosv: true) sends every package version the lockfiles it reads pin:package-lock.json,pnpm-lock.yaml,yarn.lock(Berry and Classic),bun.lock,deno.lock,uv.lock,poetry.lock,pdm.lock,Pipfile.lock,pylock.toml, requirements files pinned with==and the releases incomposer.lock(as Composer wrote them,v7.3.4or3.9.0), in the root and in every directory ofprojects, and withoutdepcurfew.jsonin the projectsdepcurfew initwould list thatdoctorcompares with the default policy (the first 20); the local data is looked up for the same lockfiles. Each version goes once, in batch queries of at most 1000 (the limit of the API), at most 20,000 versions per run. The version looked up is the one the tool installs: forpackage-lock.jsonthe registry tarball ofresolved, whose name and version npm reads from its path (/cutekitten-test/-/cutekitten-test-1.0.1.tgz), forpnpm-lock.yamlthe registry tarball ofresolution.tarballwhen there is one (a relative one too, which pnpm reads against the registry), for theyarn.lockof Yarn (Berry) the version ofresolution, and for that of Yarn Classic the registry tarball ofresolved, read as forpackage-lock.json. The path is read as the registry reads it: without a query or a fragment after the file name (.tgz?x=1,.tgz#x, which npm still fetches) and with its percent-escapes decoded; a registry tarball URL from which depcurfew reads no name and version (.TGZ) counts as an entry without an exact version, never as the version field; an entry whose name or version field says otherwise (an edited lockfile) gets a warning that names both. The ruleknown-maliciousreports each malicious version once per package and lockfile, at its line; versions OSV.dev did not answer for, or beyond the 20,000, give a warning per lockfile, so the check is not counted as passed, and so do registry entries of a lockfile that give no exact version. Requirements without an exact version (requests>=2), and Composer packages locked at a branch (dev-main,1.x-dev), are named in a note. Without--osvthe rule reads the same lockfiles and looks their versions up in the local data; without that too it does not run and shows as not applicable.- Withdrawn records (for example a report found to be a false positive) are left out of OSV.dev's answers. OSV.dev aims to be at most 15 minutes behind its sources, so a report a few minutes old may not be found yet.
Privacy. depcurfew osv update sends nothing but the request for the public export files to storage.googleapis.com, the same request for every user: no package name, nothing about the project and no credentials; the lookups in the local data happen on your machine. --osv sends the name, the ecosystem and the version of each package to api.osv.dev, the API of OSV.dev, including packages of a private registry, whose names may tell what the project uses; nothing else about the project, and no credentials. Leave --osv off where package names must not leave the network, and use the local data instead.
What it catches. Only malware that has been found and reported to the OpenSSF malicious-packages database, which OSV.dev serves, with the local data as with --osv; a new malicious release is not there until someone reports it, which is what the release-age delay of cooldown.minimum gives time for, and the local data knows only the reports up to its last depcurfew osv update. Neither covers vulnerabilities, ecosystems other than npm, PyPI and Packagist, packages that are not from a registry, nor versions a record names only by a range of commits. The database says it may contain false positives: if you think a record is wrong, read it on its page and report it there.