depcurfew 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # depcurfew
2
2
 
3
- One supply-chain security policy for npm, pnpm, Yarn, Bun, Deno, uv, pip, Poetry, PDM, Pipenv, Composer, Renovate and Dependabot, and a doctor that finds configuration that silently does nothing.
3
+ One supply-chain security policy for npm, pnpm, Yarn, Bun, Deno, uv, pip, Poetry, PDM, Pipenv, Composer, Bundler, Renovate and Dependabot, and a doctor that finds configuration that silently does nothing.
4
4
 
5
5
  ## Why
6
6
 
@@ -8,14 +8,24 @@ Package managers and update bots now each protect you against freshly published
8
8
 
9
9
  depcurfew keeps one policy file, `depcurfew.json`, writes it into the configuration of every tool in the repository, and checks that the result really works.
10
10
 
11
+ What it stops and finds:
12
+
13
+ - **Versions that are too new.** Every package manager and update bot in the repository waits `cooldown.minimum` before it takes a new version, each in its own setting, unit and file.
14
+ - **Install scripts** of dependencies that the policy does not allow, and Composer plugins.
15
+ - **Git and tarball dependencies** where the policy denies them.
16
+ - **Look-alike and made-up names.** `depcurfew check`, the [Claude Code hook](#claude-code) and the [MCP server](#mcp-server) stop a name one or two letters away from a popular npm package or PyPI project, the usual form of typosquatting and of package names an AI model invents.
17
+ - **Known malicious versions.** After `depcurfew osv update`, the same checks and `doctor` block the versions OSV.dev lists as malicious, offline, without sending a package name anywhere: see [Known malicious versions](#known-malicious-versions-osv).
18
+ - **Settings that do nothing.** `doctor` finds a pinned package manager too old for a setting, a bot that waits less than the package manager, a CI job or Dockerfile that installs with another version, and more: see [doctor rules](#doctor-rules).
19
+
11
20
  ## Five-minute start
12
21
 
13
- 1. `npx depcurfew init` writes `depcurfew.json` with the default policy below and lists the tools it detected. When the repository already sets a longer release-age delay for one of them, `cooldown.minimum` keeps the longest one, and `init` says where it found it.
14
- 2. `npx depcurfew explain` shows what it detected and what the policy means for each tool.
15
- 3. `npx depcurfew apply --dry-run` prints the changes as a diff, without writing anything.
16
- 4. `npx depcurfew apply` writes them. Run it again whenever `depcurfew.json` changes; a second run with the same policy changes nothing.
17
- 5. `npx depcurfew doctor` reports configuration that silently does not work and drift from the policy, and exits with 1 when it finds an error or, with the `"doctor": { "failOn": "warning" }` that `init` writes, a warning.
18
- 6. Add the [GitHub Action](#github-action) so that doctor runs on every push and reports to code scanning.
22
+ 1. `npx depcurfew doctor` works before `init`: without `depcurfew.json` it compares the repository with the policy `init` would write and shows, as notes in a block of their own, what `init` and `apply` would change, for example a package manager that installs new versions without a release-age delay, with the settings `apply` would write for it. These notes never make it fail.
23
+ 2. `npx depcurfew init` writes `depcurfew.json` with the default policy below and lists the tools it detected. When the repository already sets a longer release-age delay for one of them, `cooldown.minimum` keeps the longest one, and `init` says where it found it.
24
+ 3. `npx depcurfew explain` shows what it detected and what the policy means for each tool.
25
+ 4. `npx depcurfew apply --dry-run` prints the changes as a diff, without writing anything.
26
+ 5. `npx depcurfew apply` writes them. Run it again whenever `depcurfew.json` changes; a second run with the same policy changes nothing.
27
+ 6. `npx depcurfew doctor` reports configuration that silently does not work and drift from the policy, and exits with 1 when it finds an error or, with the `"doctor": { "failOn": "warning" }` that `init` writes, a warning.
28
+ 7. Add the [GitHub Action](#github-action) so that doctor runs on every push and reports to code scanning.
19
29
 
20
30
  The default policy:
21
31
 
@@ -79,6 +89,41 @@ npm run build
79
89
  node dist/cli.js doctor /path/to/your/repository
80
90
  ```
81
91
 
92
+ ### Verifying a release
93
+
94
+ Every release is built and published from its git tag by the [Release workflow](https://github.com/DevXord/depcurfew/blob/main/.github/workflows/release.yml) with npm trusted publishing, so the npm page of each version shows its provenance: the repository, the workflow and the commit it was built from. One job of the workflow installs, tests and packs the package without the right to publish it; a second job, which runs no code of the repository or of its dependencies, stages exactly the file the first one packed, after comparing its SHA-512 checksum. After installing, `npm audit signatures` checks the registry signature and the provenance attestation of every installed package, depcurfew included.
95
+
96
+ The package contains nothing but the build of that commit, and anyone can check it: check out the tag, build it and compare the checksum with the one the registry serves, here for 1.4.1:
97
+
98
+ <!-- BEGIN GENERATED: verify-release -->
99
+ ```bash
100
+ git clone --branch v1.4.1 https://github.com/DevXord/depcurfew.git
101
+ cd depcurfew
102
+ npm ci --ignore-scripts
103
+ npm run build
104
+ npm pack --dry-run --json | grep shasum
105
+ npm view depcurfew@1.4.1 dist.shasum
106
+ ```
107
+ <!-- END GENERATED: verify-release -->
108
+
109
+ The two checksums are the same. For 1.5.0 they differ: its JavaScript files are the build of the tag byte for byte, but its declaration file `dist/index.d.ts` lists the properties of the policy types in another order, which a build that ran right after the tests produced; later releases write those types out, so every build gives the same file. The [Verify release workflow](https://github.com/DevXord/depcurfew/blob/main/.github/workflows/verify-release.yml) does this for the newest version on npm every day and on demand, and its runs are public in the Actions tab of the repository.
110
+
111
+ ## Step-by-step guide
112
+
113
+ The [five-minute start](#five-minute-start) lists the commands; this guide goes through them once, in a repository you know, with what to look at in each step.
114
+
115
+ 1. **See what is missing.** Run `npx depcurfew doctor` in the root of the repository. Without `depcurfew.json` it compares the repository with the policy `init` would write and lists, under "Compared with the default policy (no depcurfew.json):", every file `apply` would create or change and why: a package manager that installs new versions without a release-age delay, dependencies whose install scripts would run, an update bot that proposes versions sooner than the package manager would install them. Nothing fails yet; these are notes.
116
+ 2. **Write the policy.** `npx depcurfew init` writes `depcurfew.json` and says what it found. Read its notes: it keeps a longer release-age delay the repository already has, puts well-known packages whose install scripts are not needed into `installScripts.deny`, and lists the packages with install scripts to review. Allow the ones the project needs in `installScripts.allow`; every other entry of [the policy file](#the-policy-file) has a safe default.
117
+ 3. **Look before writing.** `npx depcurfew explain` shows the tools it detected and what the policy means for each one, including the tools that cannot enforce a part of it. `npx depcurfew apply --dry-run` prints the changes as a diff.
118
+ 4. **Apply and commit.** `npx depcurfew apply` writes the configuration into the files each tool reads, keeps their comments and formatting, and marks the parts it manages. Commit `depcurfew.json` and the changed files together: from now on the team and CI get the protection from those files, whether or not they run depcurfew.
119
+ 5. **Check that it works.** `npx depcurfew doctor` now compares the repository with `depcurfew.json`. Fix what it reports; each finding says where it is and how to fix it, and [doctor rules](#doctor-rules) explains every rule. A typical first finding is a CI job or Dockerfile that installs with an older package manager than the one the setting needs.
120
+ 6. **Block known malicious versions.** `npx depcurfew osv update` downloads the public OSV.dev data of known malicious packages for npm, PyPI and Packagist; `check`, the hook, `depcurfew mcp` and `doctor` then block those versions offline. Run it again regularly; `doctor` says when the copy is more than a week old.
121
+ 7. **Keep it in CI.** Add the [GitHub Action](#github-action), pinned to a release commit, so that `doctor` runs on every push and pull request and reports to code scanning. On other CI systems, run the project's own copy (see [Other CI systems](#other-ci-systems)).
122
+ 8. **Guard installs by coding agents.** With Claude Code, add the [hook](#claude-code), which checks every install command before it runs; for other agents, add the [MCP server](#mcp-server).
123
+ 9. **Protect the whole machine (optional).** `npx depcurfew init --user` and `npx depcurfew apply --user` give every project on the computer the same release-age delay and script blocking, also projects without depcurfew: see [Protecting the whole machine](#protecting-the-whole-machine).
124
+
125
+ When `depcurfew.json` changes later, run `npx depcurfew apply` again and commit the result; a second run with the same policy changes nothing.
126
+
82
127
  ## The policy file
83
128
 
84
129
  Every section is optional: a section that is absent means depcurfew does not manage that area. Durations are converted to each tool's own unit and always rounded up, so a tool never waits less than the policy says. Where a tool cannot express part of the policy, depcurfew says so instead of skipping it silently.
@@ -121,7 +166,7 @@ backend/ pyproject.toml, uv.lock
121
166
 
122
167
  List those directories under `projects`, relative to the repository root with `/` between directories (no patterns, no `..`, no `/` at either end). `apply`, `doctor` and `explain` then treat each listed directory as a project of its own with the policy of the root: its own `.npmrc`, lockfiles and workspaces, as if the command ran there. The root is configured as before. Renovate and Dependabot are configured once, from the repository root, where they read their configuration, and wait at least as long as the package manager of any project (in the example, npm rounds `36h` up to 2 days, so every Dependabot entry gets `default-days: 2`). Every path in the output is relative to the root (`frontend/.npmrc`), and all files of all projects are written or none. A `renovate.json` or `.github/dependabot.yml` inside a project is never read by the bot, so `doctor` gives a note at it instead of comparing its delay (and says so when the root has no configuration of that bot at all), and "What was checked" marks it as not read; a project that the Dependabot configuration of the root leaves out while it updates other projects gets a warning.
123
168
 
124
- What the root gives a project, for the npm it runs: the CI jobs of the root that install in its directory (`working-directory: frontend`, or a literal `cd frontend` before the install in the same shell); the `.nvmrc`, `.node-version` and `.tool-versions` of the root (nvm, nodenv and asdf use those files in subdirectories too), each unless the project has a file of the same kind, because each tool reads only its own file: with `frontend/.nvmrc`, asdf still uses the root `.tool-versions` in `frontend`; and the stages of a root Dockerfile that copy files only from that project (`COPY frontend/package.json frontend/package-lock.json ./`). Copy sources are read as Docker reads them, from the root of the build context with `.` and `..` resolved (`COPY frontend/../ .` copies the whole context, `/frontend/x` is `frontend/x`). A stage that copies `.` or files of several directories is the root's, because it is not clearly a build of one project, except an install after a literal `cd frontend` (`RUN cd frontend && npm ci`), which is the project's. A root CI job or Dockerfile stage that installs another npm (`npm install -g npm@<version>`) before installing in a project is checked with the settings of that project. Volta, `devEngines` and `engines` in the root `package.json` do not count for a project, which has a `package.json` of its own. `explain` lists, for each project, the root version files and root Dockerfile stages it uses (`fromRoot` in `--json`).
169
+ What the root gives a project, for the npm it runs: the CI jobs of the root that install in its directory (`working-directory: frontend`, or a literal `cd frontend` before the install in the same shell); the `.nvmrc`, `.node-version` and `.tool-versions` of the root (nvm, nodenv and asdf use those files in subdirectories too), each unless the project has a file of the same kind, because each tool reads only its own file: with `frontend/.nvmrc`, asdf still uses the root `.tool-versions` in `frontend`; and the stages of a root Dockerfile that copy files only from that project (`COPY frontend/package.json frontend/package-lock.json ./`). Copy sources are read as Docker reads them, from the root of the build context with `.` and `..` resolved (`COPY frontend/../ .` copies the whole context, `/frontend/x` is `frontend/x`). A stage that copies `.` or files of several directories is the root's, because it is not clearly a build of one project, except an install after a literal `cd frontend` (`RUN cd frontend && npm ci`), which is the project's. A root CI job or Dockerfile stage that installs another npm (`npm install -g npm@<version>`) before installing in a project is checked with the settings of that project. Volta, `devEngines` and `engines` in the root `package.json` do not count for a project, which has a `package.json` of its own. `explain` lists, for each project, the root version files and root Dockerfile stages it uses (`fromRoot` in `--json`). A version a setup step of a root CI job installs (`astral-sh/setup-uv` with `version: 0.13.2`) is the project's too when every run step of that job works in its directory (`working-directory: backend` or `defaults.run.working-directory`), so `apply` configures the project for it.
125
170
 
126
171
  A package manager that the root shows only through a CI setup step (such as `pnpm/action-setup` or `oven-sh/setup-bun` in a workflow of the root) is set up there for the projects, not used by the root, when the root has no manifest of any tool of its registry (for pnpm, Bun and Deno no `package.json`, `package.json5`, `package.yaml`, `deno.json` or `deno.jsonc`; for uv and Poetry no `pyproject.toml`, `requirements.txt` or `Pipfile`), or when every install with it in the root's workflows runs in a listed directory (by `working-directory` or a literal `cd`) and no Dockerfile of the root installs with it. Then `apply` writes nothing for it in the root, `doctor` does not list it among the tools of the root or compare Renovate and Dependabot with it, and `explain` lists it under "Package managers set up in CI for projects" (`ciForProjects` in `--json`); `manager-version-too-old` still checks the version the step installs. When the root has none of its manifests and no project uses it either, `apply` names it in `tool-only-in-ci` as before. Without `projects` such a step counts for the root, as before.
127
172
 
@@ -135,13 +180,14 @@ The package installs two names for the same command: `depcurfew` and the short `
135
180
 
136
181
  | Command | What it does |
137
182
  | --- | --- |
138
- | `depcurfew init [directory] [--force] [--json]` | Writes `depcurfew.json` with the default policy (keeping a longer release-age delay the repository already sets); `--force` overwrites an existing one, `--json` prints the result as JSON. |
139
- | `depcurfew explain [directory] [--json]` | Describes the detected package managers, versions, bots and workspaces, and what the policy means for each tool: the release-age delay, and the settings `apply` writes for `installScripts` and `sources`, or why a tool cannot enforce them. |
183
+ | `depcurfew init [directory] [--force] [--json]` | Writes `depcurfew.json` with the default policy, keeping what the repository already sets: a longer release-age delay, a dependency source an `.npmrc` denies, a delay of security updates in Renovate, the cooldown exceptions of the tools' own lists and the packages their install-script allow-lists approve, each with a note to review; `--force` overwrites an existing one, `--json` prints the result as JSON. |
184
+ | `depcurfew explain [directory] [--json]` | Describes the detected package managers, versions, bots and workspaces, and what the policy means for each tool: the release-age delay (or why `apply` writes none, such as a pinned version too old for it), and the settings `apply` writes for `installScripts` and `sources`, or why a tool cannot enforce them. |
140
185
  | `depcurfew apply [directory] [--dry-run] [--json]` | Writes the policy into the configuration of every target tool; `--dry-run` prints a unified diff instead, `--json` the result as JSON. |
141
- | `depcurfew doctor [directory] [--json \| --sarif] [--fail-on error\|warning] [--osv]` | Finds configuration that silently does not work and drift from the policy; `--sarif` prints SARIF 2.1.0 for code scanning, and `--fail-on` overrides `doctor.failOn` of the policy (`--strict` is `--fail-on warning`). `--osv` also looks every locked package version up in OSV.dev: see [Known malicious versions](#known-malicious-versions-osv). |
186
+ | `depcurfew doctor [directory] [--json \| --sarif] [--fail-on error\|warning] [--osv]` | Finds configuration that silently does not work and drift from the policy, and without `depcurfew.json` shows what the default policy would change; `--sarif` prints SARIF 2.1.0 for code scanning, and `--fail-on` overrides `doctor.failOn` of the policy (`--strict` is `--fail-on warning`). `--osv` also looks every locked package version up in OSV.dev: see [Known malicious versions](#known-malicious-versions-osv). |
142
187
  | `depcurfew check <package...> [--json] [--dir <directory>] [--registry <url>] [--index <url>] [--osv]` | Checks packages against the policy before you install them: see [Guarding installs](#guarding-installs). |
143
188
  | `depcurfew check --hook claude-code [--osv]` | The same check as a Claude Code hook that blocks install commands breaking the policy. |
144
189
  | `depcurfew mcp [--dir <directory>] [--registry <url>] [--index <url>] [--osv]` | The same check as tools of a Model Context Protocol server, for AI agents in Claude Desktop, VS Code and other clients: see [MCP server](#mcp-server). |
190
+ | `depcurfew osv update [--ecosystem npm\|pypi\|packagist ...] [--accept-fewer] [--json]` | Downloads the public OSV.dev data of known malicious packages, which `check`, the hook, `mcp` and `doctor` then use offline, without `--osv`: see [Known malicious versions](#known-malicious-versions-osv). |
145
191
  | `depcurfew init --user [--force] [--json]` | Writes the user policy, which `--user` uses: see [Protecting the whole machine](#protecting-the-whole-machine). |
146
192
  | `depcurfew apply --user [--dry-run] [--json] [--for <tool@version>]` | Writes the user policy into the user-level configuration of the installed package managers. |
147
193
  | `depcurfew doctor --user [--json \| --sarif] [--fail-on error\|warning] [--for <tool@version>]` | Checks that configuration against the user policy, and the environment variables that override it. |
@@ -168,9 +214,11 @@ In `explain --json`, `policy.installScripts` holds `default`, `allow`, `deny` an
168
214
  How `apply` edits files:
169
215
 
170
216
  - It keeps comments, formatting, line endings and key order. In line-based files (INI, TOML) it writes a block between `# >>> depcurfew managed block` and `# <<< depcurfew managed block` markers and comments out older copies of the settings it manages (they come back when the policy stops managing them); in YAML it marks the keys it writes with `# managed by depcurfew`; JSON files keep their comments and indentation.
171
- - It reads files the way the tool does. In YAML files of a tool whose loader applies merge keys (`<<: *defaults` in `pnpm-workspace.yaml` and pnpm's `config.yaml`), a setting a merge key brings in counts, and `apply` writes its value into the map itself, which wins over merged keys; `.yarnrc.yml` and `dependabot.yml` are read without merging. A switch that allows every install script counts as on for every value the tool does not read as off: pnpm runs every build script for `dangerouslyAllowAllBuilds: "false"` (a string) or `yes`, and npm and pnpm 10 for `=1` or `=yes` in `.npmrc`.
172
- - It never removes settings it does not manage. Allow-lists are only added to; exception lists follow the policy, except the entries Renovate adds to pnpm's exception list for security updates, which stay with their comment. An entry in a tool's own install-script list that contradicts `installScripts` (it lets a package of `installScripts.deny` run its scripts, or blocks one of `installScripts.allow`) stays as it is, with the warning `deny-conflicts-with-file`, and `apply` records no denial for that package.
173
- - When it replaces a longer release-age delay someone set by hand with the policy's shorter one, it warns (`cooldown-shortened`) and names the `cooldown.minimum` that keeps it.
217
+ - It reads files the way the tool does. In YAML files of a tool whose loader applies merge keys (`<<: *defaults` in `pnpm-workspace.yaml` and pnpm's `config.yaml`), a setting a merge key brings in counts, and `apply` writes its value into the map itself, which wins over merged keys; `.yarnrc.yml` and `dependabot.yml` are read without merging. A switch that allows every install script counts as on for every value the tool does not read as off: pnpm runs every build script for `dangerouslyAllowAllBuilds: "false"` (a string) or `yes`, npm and pnpm 10 for `=1` or `=yes` in `.npmrc`, and Yarn for `enableScripts: 1` or `"true"`. TOML table names and keys are read as TOML reads them, so `[tool."uv"]`, `[tool . uv]` and `["install"]` are the tables `[tool.uv]` and `[install]`.
218
+ - An install-script allow-list of another type than the tool reads (a list where npm and Yarn take an object of package names, an object where Bun takes a list) stays as it is, with the warning `unexpected-value`, since replacing it would drop the packages it names.
219
+ - It never removes settings it does not manage. Allow-lists are only added to; exception lists follow the policy, except the entries Renovate adds to pnpm's exception list for security updates, which stay with their comment. An exception written by hand that the exceptions of the policy do not name is commented out (in YAML kept as a comment above the list it writes, in JSON removed), with the warning `exclusion-dropped`, which names the patterns that keep it. An entry in a tool's own install-script list that contradicts `installScripts` (it lets a package of `installScripts.deny` run its scripts, or blocks one of `installScripts.allow`) stays as it is, with the warning `deny-conflicts-with-file`, and `apply` records no denial for that package.
220
+ - When it replaces a longer release-age delay someone set by hand with the policy's shorter one, it warns (`cooldown-shortened`) and names the `cooldown.minimum` that keeps it. A fixed date in place of a delay, which uv, PDM and Deno accept (`"2024-01-01"`), counts as the whole days since it. A value depcurfew cannot read (Composer's `"1 month"` or `"P30D"`, which Composer rejects too, Deno's `"3 days"`) stays as it is, with `cooldown-not-written`.
221
+ - When it makes a setting it did not write less strict, it warns as well: a dependency source that `.npmrc` limits with `none` or `root`, pnpm's switch for git and tarball sources and a Yarn list of approved git repositories without `**`, under a policy that allows those sources (`source-loosened`), and a Renovate delay of security updates under `securityUpdates: "bypass"` (`security-delay-removed`).
174
222
  - If any file cannot be edited, nothing is written. If a file cannot be written, the files already written are put back.
175
223
  - It does not create bot configuration files or project manifests (`package.json`, `deno.json`, `pyproject.toml`, `Pipfile`, `requirements.txt`); it creates a package manager's configuration file when it is missing. A package manager seen only in a CI setup step, with nothing of it in the repository, is not configured under `targets: "auto"`.
176
224
  - It warns when a file it writes is ignored by git (`.gitignore` or `.git/info/exclude`), because the change would then stay on one machine.
@@ -184,25 +232,29 @@ Install scripts: `init` reads the lockfiles (`package-lock.json`, `pnpm-lock.yam
184
232
  <!-- BEGIN GENERATED: init-codes -->
185
233
  Warnings:
186
234
 
187
- - `project-left-out`: A direct subdirectory with a manifest or lockfile is left out of projects, because depcurfew apply would refuse it (it has a depcurfew.json of its own, or it is a separate git repository).
235
+ - `project-left-out`: A direct subdirectory with a manifest or lockfile is left out of projects, because depcurfew apply would refuse it (it has a depcurfew.json of its own, it is a separate git repository, or its name differs from that of a listed directory only in case).
188
236
 
189
237
  Notes:
190
238
 
191
239
  - `projects-listed`: The repository root has no manifest or lockfile, but direct subdirectories do, so init lists them under projects in depcurfew.json.
192
- - `cooldown-kept`: The repository (with --user, the user file of an installed tool) already sets a longer release-age delay, so cooldown.minimum keeps it instead of the default.
240
+ - `cooldown-kept`: The repository (with --user, the user file of an installed tool) already sets a longer release-age delay, so cooldown.minimum keeps it instead of the default; a fixed date of uv, PDM or Deno counts as the whole days since it.
241
+ - `sources-kept`: An .npmrc of the repository already denies a dependency source the default policy allows (allow-file=none, allow-directory=none), so init writes "deny" for it in sources.
242
+ - `security-delay-kept`: Renovate already delays security updates (vulnerabilityAlerts.minimumReleaseAge), so init writes cooldown.securityUpdates "delay" instead of "bypass", which would make depcurfew apply remove the delay.
243
+ - `exclusions-kept`: The tools' own exception lists already exempt packages from the release-age delay (npm's min-release-age-exclude, pnpm's minimumReleaseAgeExclude, Dependabot's cooldown.exclude...), so init lists them in cooldown.exclude, where depcurfew apply keeps them; review each before depcurfew apply.
244
+ - `install-scripts-kept`: The package managers' own allow-lists already let packages run install scripts (pnpm's allowBuilds or onlyBuiltDependencies, Bun's trustedDependencies, Yarn's dependenciesMeta, npm's or Deno's allowScripts), so init lists them in installScripts.allow; review each before depcurfew apply.
193
245
  - `python-builds-denied`: installScripts.default "deny" also blocks building Python packages from source, so a package without wheels for the platform stops installing until it is allowed.
194
246
  - `tool-only-in-ci`: A tool appears only in a CI setup step, so depcurfew apply does not configure it under targets "auto".
195
247
  - `install-script-denied`: A lockfile has a package whose install script depcurfew knows it does not need (esbuild, fsevents), so init adds it to installScripts.deny; the note says what changes without the script.
196
248
  - `install-script-project-task`: A lockfile has a package whose install script only does a task of the project (lefthook installs the git hooks), so init adds it to installScripts.deny; the fix says how the project runs that task itself.
197
- - `install-scripts-to-review`: A lockfile has other packages with install scripts, or composer.lock has Composer plugins (the note says which ones config.allow-plugins in composer.json already allows), which installScripts.default "deny" blocks, or packages depcurfew knows that a package manager's own allow-list already allows, which init leaves out of installScripts.deny; review each before depcurfew apply.
249
+ - `install-scripts-to-review`: A lockfile has other packages with install scripts, or composer.lock has Composer plugins (the note says which ones config.allow-plugins in composer.json already allows), which installScripts.default "deny" blocks; review each before depcurfew apply.
198
250
  - `composer-source-checkouts`: config.preferred-install in composer.json makes Composer install packages of composer.lock from their VCS repositories, which sources.git "deny" does not allow, so depcurfew doctor reports them; the note says which entry and which packages.
199
251
  - `bot-config-unreadable`: The Dependabot configuration uses a YAML alias, which Dependabot does not accept, so it reads no configuration from the file, and init does not read the delays it sets when it chooses cooldown.minimum.
200
- - `install-scripts-not-recorded`: A lockfile does not record which packages have install scripts (pnpm-lock.yaml, yarn.lock), or is in a format depcurfew does not read, so init can name only the packages depcurfew knows.
252
+ - `install-scripts-not-recorded`: A lockfile does not record which packages have install scripts (pnpm-lock.yaml, yarn.lock, a bun.lock that marks none, which Bun before 1.4.4 writes), or is in a format depcurfew does not read, so init can name only the packages depcurfew knows.
201
253
  <!-- END GENERATED: init-codes -->
202
254
 
203
255
  ### apply warnings and errors
204
256
 
205
- Each warning and error of `apply` has a code. The text output shows it in parentheses after the message, with what to do on the next line, like a doctor finding. `depcurfew apply --json` (also with `--dry-run`) prints one JSON document and nothing on stderr: `changes` (each file with its action and unified diff), `warnings` and `errors` (each with `code`, `level`, `message` and `fix`; `tool` when it concerns one tool; `location` when it concerns one file, with `file` and, when known, `line`; for an invalid policy also `issues`), `snippets` to add by hand, `notices` (such as `policy-from-parent`) and `written`. A run that writes nothing because of an error lists no changes and exits with 1. Diffs, on the terminal, in a pipe and in `changes`, show the values of secrets as `<redacted>` (`_authToken`, `_auth`, `_password` and the client key `key` in `.npmrc`, keys ending in `token` or `password`, the user and password of a URL), so that a diff can be shared or logged; the files keep them. The text diff also shows control and bidirectional characters from the files as text (`\x1b`, `‮`), so that a value cannot drive the terminal or a CI log; `changes` keeps them, escaped by JSON.
257
+ Each warning and error of `apply` has a code. The text output shows it in parentheses after the message, with what to do on the next line, like a doctor finding. `depcurfew apply --json` (also with `--dry-run`) prints one JSON document and nothing on stderr: `changes` (each file with its action and unified diff), `warnings` and `errors` (each with `code`, `level`, `message` and `fix`; `tool` when it concerns one tool; `location` when it concerns one file, with `file` and, when known, `line`; for an invalid policy also `issues`), `snippets` to add by hand, `notices` (such as `policy-from-parent`) and `written`. A run that writes nothing because of an error lists no changes and exits with 1. Diffs, on the terminal, in a pipe and in `changes`, show the values of secrets as `<redacted>` (`_authToken`, `_auth`, `_password` and the client key `key` in `.npmrc`, keys ending in `token` or `password`, the user part of a URL up to the last `@` before its host, also a token alone such as `https://TOKEN@host` or `git+https://ghp_…@github.com`, but not the login name of an ssh URL such as `git+ssh://git@github.com`), also in a line of `.npmrc` or `pip.conf` that a lone carriage return splits in two, as npm and pip read it, so that a diff can be shared or logged; the files keep them. The text diff also shows control and bidirectional characters from the files as text (`\x1b`, `‮`), so that a value cannot drive the terminal or a CI log; `changes` keeps them, escaped by JSON.
206
258
 
207
259
  <!-- BEGIN GENERATED: apply-codes -->
208
260
  Warnings:
@@ -216,6 +268,7 @@ Warnings:
216
268
  - `tool-only-in-ci`: A tool appears only in a CI setup step, with no manifest, lockfile or configuration in the repository, so targets "auto" does not configure it.
217
269
  - `version-not-pinned`: No exact version of a package manager is pinned, so depcurfew configures for the latest version it knows.
218
270
  - `version-too-old`: The pinned version of a tool (with apply --user, the installed one) lacks a setting the policy needs, so that setting is not written.
271
+ - `version-in-use-too-old`: Another npm the repository runs than the pinned one (one a declared Node.js comes with, such as Node.js 24 in .nvmrc, or a CI job or Dockerfile installs) lacks a setting apply writes for the release-age delay or a dependency source, so that setting has no effect there; doctor reports the same places as manager-version-too-old. A place that only runs npm ci needs no delay.
219
272
  - `version-unknown`: apply --user: the installed version of a tool cannot be read from its files, and a value it does not understand would stop every command, so nothing is written for it.
220
273
  - `no-user-config`: apply --user: the tool has no user-level configuration file, so nothing is written for it.
221
274
  - `user-setting-project-only`: apply --user: the tool reads a part of the policy only from project files, so it is not written into the user file.
@@ -223,27 +276,31 @@ Warnings:
223
276
  - `user-file-uncertain`: apply --user: a library the tool calls decides where its user file is (platformdirs for Poetry and PDM on macOS), and its version cannot be read from files, so apply --user writes where the tool's docs say and names the other files the tool may read.
224
277
  - `user-setting-overridden`: apply --user: a file the tool reads ahead of its user file (~/.npmrc for pnpm 10) sets a key apply writes there to another value, so the value written has no effect.
225
278
  - `version-not-used`: apply --user: --for gives the version of a tool that apply --user does not configure (not in PATH with targets "auto", or not listed under targets).
226
- - `cooldown-not-written`: The release-age delay cannot be written for a tool.
227
- - `cooldown-shortened`: A release-age delay set outside depcurfew's markers (in JSON files also one an earlier apply wrote) is longer than the policy, and apply replaces it with the shorter one.
279
+ - `cooldown-not-written`: The release-age delay cannot be written for a tool (for Bundler: a source call of the Gemfile apply does not edit, no source "https://rubygems.org", or a Gemfile depcurfew cannot read as Ruby does from the line given on, which it then does not edit at all; for pnpm 12.15 and later: include in pnpm-workspace.yaml gives minimumReleaseAge to a config dependency or to a file outside the directory).
280
+ - `cooldown-shortened`: A release-age delay set outside depcurfew's markers (in JSON files also one an earlier apply wrote) is longer than the policy, and apply replaces it with the shorter one; a fixed date of uv, PDM or Deno counts as the time since that date.
228
281
  - `cooldown-environment-only`: pip reads no project configuration, so the release-age delay has to be set in the environment where pip runs.
229
282
  - `exclude-pattern-unsupported`: A cooldown exception uses a wildcard the tool does not support, so it is not written.
230
- - `exclude-unsupported`: The tool has no setting for cooldown exceptions, so the release-age delay applies to every package.
283
+ - `exclude-unsupported`: The tool has no setting for cooldown exceptions, so the release-age delay applies to every package (Bundler: also because the policy has no names for Ruby gems).
231
284
  - `exclude-not-written`: The setting that holds the cooldown exceptions (Composer's config.policy.cooldown.ignore) has a value of a type the tool does not accept there, so apply leaves it as it is and does not add the exceptions; the release-age delay is written.
232
285
  - `exclude-entry-outside-policy`: A cooldown exception in a file without depcurfew's markers (Composer's config.policy.cooldown.ignore) exempts a package that cooldown.exclude does not; apply removes only the entries it wrote, recognised by their reason, and leaves this one.
233
286
  - `allow-pattern-unsupported`: An installScripts.allow entry is a pattern, but the tool takes package names only, so it is not written.
234
287
  - `deny-pattern-unsupported`: An installScripts.deny entry is a pattern, but the tool records denials by package name only, so it is not written.
235
288
  - `allow-all-unsupported`: The tool has no setting that allows every install script, so installScripts "allow" is not written.
236
289
  - `allow-all-replaced`: A setting that allowed every install script (with any value the tool reads as true, also one that a YAML merge key brings in, or pnpm 10's dangerously-allow-all-builds in .npmrc) was set to false, or replaced with the policy's allow-list where the setting is the list itself (Deno's allowScripts, Composer's allow-plugins, which then may be {}), because the policy denies install scripts.
237
- - `allow-list-entry-outside-policy`: An install-script allow-list in the repository (with apply --user, in a user file) lets a package run scripts the policy does not allow; apply does not remove it.
290
+ - `allow-list-entry-outside-policy`: An install-script allow-list in the repository (with apply --user, in a user file) lets a package run scripts the policy does not allow, or will once the tool reads the list (Deno before 2.6.0 does not read allowScripts); apply does not remove it.
238
291
  - `deny-conflicts-with-file`: An entry in a tool's own list lets a package of installScripts.deny run its install scripts, or blocks a package of installScripts.allow; apply does not change entries it did not write.
239
292
  - `deny-needs-optional-dependencies`: A package of installScripts.deny takes its binary from an optional dependency (esbuild), but npm leaves optional dependencies out (omit=optional or optional=false in .npmrc, --omit=optional or --no-optional on an install of a CI job or Dockerfile, NPM_CONFIG_OMIT=optional), so the package cannot find its binary after that install.
240
293
  - `unexpected-value`: A setting apply adds to has a value of a type the tool does not accept there, so apply leaves it as it is and writes nothing into it.
241
294
  - `built-in-list-replaced`: Bun's built-in list of trusted packages is replaced by the policy's allow-list.
242
- - `scripts-not-enforceable`: The pinned (with apply --user, the installed) npm or pnpm, or another npm the repository runs (one a declared Node.js comes with, or a CI job or Dockerfile installs), runs every install script and has no allow-list, so "deny" cannot be enforced; also given for an npm the repository runs whose version depcurfew cannot tell, which counts as one that runs them.
295
+ - `scripts-not-enforceable`: The pinned (with apply --user, the installed) npm or pnpm, or another npm the repository runs (one a declared Node.js comes with, or a CI job or Dockerfile installs), runs every install script and has no allow-list, so "deny" cannot be enforced; also given for an npm the repository runs whose version depcurfew cannot tell, which counts as one that runs them, and for Bundler, which builds the native extensions of gems at install time and has no setting that stops it.
243
296
  - `strict-allow-scripts-for-npm-in-use`: An npm the repository runs (the pinned one, one a CI job or Dockerfile installs, or one a declared Node.js comes with, for a project also through the root version files and root Dockerfiles that build it) is 11.16 to 11.x and runs install scripts the allow-list does not cover unless strict-allow-scripts is set, so apply writes strict-allow-scripts=true, which makes npm 12 stop the install at such a script; not given when that npm is the pinned one and no place runs npm 12.
244
297
  - `before-overrides-cooldown`: before= in .npmrc takes the place of the release-age delay apply writes, or makes npm stop with an error next to it (apply --user then does not write the delay).
245
298
  - `exact-version-gap`: The pinned (with apply --user, the installed) pnpm skips the release-age delay for packages requested by exact version.
246
- - `source-not-enforceable`: The tool has no setting for a dependency source the policy denies; doctor checks the lockfile instead.
299
+ - `setting-owned-elsewhere`: pnpm 12.15 and later: include in pnpm-workspace.yaml gives a setting the policy needs to a config dependency or to a file outside the directory, so apply does not write it (pnpm refuses a setting with two owners, ERR_PNPM_DUPLICATE_SETTING) and cannot check its value; the release-age delay itself gives cooldown-not-written.
300
+ - `source-not-enforceable`: The tool has no setting for a dependency source the policy denies; doctor checks the lockfile instead, where it reads it (not Gemfile.lock).
301
+ - `source-loosened`: A dependency-source setting outside depcurfew's markers is stricter than the policy (npm's allow-file or allow-directory none or root, pnpm's blockExoticSubdeps true, a Yarn approvedGitRepositories list without **), and apply replaces it with the value the policy's "allow" gives.
302
+ - `security-delay-removed`: Renovate delays security updates (vulnerabilityAlerts.minimumReleaseAge), but cooldown.securityUpdates is "bypass", so apply sets it to null and Renovate proposes them at once.
303
+ - `exclusion-dropped`: A cooldown exception set outside depcurfew's markers (an entry of npm's min-release-age-exclude, pnpm's minimumReleaseAgeExclude, Yarn's npmPreapprovedPackages, Bun's minimumReleaseAgeExcludes, Deno's minimumDependencyAge.exclude, uv's exclude-newer-package, PDM's exclude-newer-override, Poetry's min-release-age-exclude or Dependabot's cooldown.exclude) is not in cooldown.exclude, so apply comments it out (removes it in JSON), and the package waits again.
247
304
  - `source-switch-indirect-only`: The pnpm setting for git and tarball sources stops them only for indirect dependencies.
248
305
  - `source-switch-combined`: One pnpm setting covers git and tarball sources, so denying one denies both.
249
306
  - `forced-source-build`: Python packages on the allow-list are always built from source, because no tool can only allow a build.
@@ -251,19 +308,21 @@ Warnings:
251
308
  - `build-switch-install-only`: Poetry applies its build switch when installing; poetry lock may still build source distributions.
252
309
  - `build-switch-environment-only`: The tool has no project setting that forbids building from source, so it has to be set in the environment where the tool runs.
253
310
  - `requirements-file-missing`: There is no requirements file to write pip options into.
254
- - `manifest-missing`: A setting belongs in a project manifest (package.json, deno.json, pyproject.toml, Pipfile, composer.json) that does not exist, and apply does not create manifests.
311
+ - `build-switch-undone`: A line of a requirements file pip reads (requirements.txt or a file it includes with -r or -c) has --no-binary or --only-binary=:none:, which undoes the --only-binary=:all: apply writes, and apply cannot comment it out: the option follows a requirement, shares its line with other options or continues on the next line.
312
+ - `requirements-include-unread`: A requirements file pip reads includes another one (-r, -c) that apply cannot check for lines that undo --only-binary=:all: (a URL, a path outside the directory configured, a missing file, or a file it cannot read).
313
+ - `manifest-missing`: A setting belongs in a project manifest (package.json, deno.json, pyproject.toml, Pipfile, composer.json, Gemfile) that does not exist, and apply does not create manifests.
255
314
  - `lockfile-out-of-date`: The lockfile records, or is checked against, a setting apply changed, so it has to be locked again.
256
315
  - `file-ignored-by-git`: A file apply writes matches a .gitignore rule, so the change may never reach the repository.
257
316
  - `bot-config-missing`: A bot has no configuration file, and depcurfew does not create one.
258
317
  - `bot-config-not-edited`: A bot configuration file is in a place or format depcurfew does not edit; a snippet shows what to add.
259
318
  - `bot-entries-missing`: The Dependabot configuration has no entries for the package managers in use.
260
319
  - `bot-config-unreadable`: The Dependabot configuration uses a YAML alias, which Dependabot does not accept, so it reads no configuration from the file (the cooldown apply writes included), and depcurfew does not read the delays the file sets.
261
- - `bot-rule-shortens-delay`: A Renovate packageRules entry apply does not manage gives every package of a registry, Renovate manager or category in use, or the actions of the workflows, or some update types of them, a shorter release-age delay than the one apply writes; Renovate uses it, and apply does not change it.
320
+ - `bot-rule-shortens-delay`: A Renovate packageRules entry apply does not manage gives every package of a registry, Renovate manager or category in use, the actions of the workflows or the dependencies of an ecosystem updated by bots only (Cargo, Go modules, Maven, Gradle, NuGet), or some update types of them, a shorter release-age delay than the one apply writes; or a Dependabot updates entry apply writes the cooldown into has a cooldown.include list, which limits the cooldown to the dependencies it names, or keeps a cooldown.exclude list that matches every dependency (an entry for actions, Ruby gems or an ecosystem updated by bots only, whose list apply does not replace). The bot uses these, and apply does not change them.
262
321
  - `security-delay-unsupported`: Dependabot cannot delay security updates, so securityUpdates "delay" is not applied.
263
322
 
264
323
  Errors (nothing is written):
265
324
 
266
- - `cannot-edit`: A file cannot be read or edited safely (invalid syntax, a symlink, a style depcurfew does not edit), so nothing is written.
325
+ - `cannot-edit`: A file cannot be read or edited safely (invalid syntax, a symlink, a style depcurfew does not edit, a file over the size depcurfew reads, 1 MiB for configuration and 64 MiB for lockfiles, also one that the change would make larger than that), so nothing is written.
267
326
  - `cannot-write`: A file cannot be written (no permission, a read-only file system, a full disk, a directory or symlink in its place); the files already written are put back, so nothing changes.
268
327
  - `policy-missing`: The directory has no depcurfew.json.
269
328
  - `policy-invalid`: depcurfew.json does not follow the schema; issues lists the problems.
@@ -299,8 +358,8 @@ depcurfew doctor --user
299
358
  - `apply --user` writes the files of all tools or none, as `apply` does in a project: a user file it cannot read or edit (not UTF-8, a symlink, larger than 1 MiB, a file where its directory belongs) is an error that names it, such as `~/.config/pip/pip.conf`, with a fix for that problem, and a file that cannot be written puts back the ones already replaced. `init --user` and `apply --user` do not create a missing home directory, and when `XDG_CONFIG_HOME` or `%APPDATA%` is a relative path, `apply --user` writes nothing for the tools that may use it, with a warning (pip then reads `pip.conf` relative to the directory each command runs in). Run as root (`sudo` with `HOME` kept), they leave each file they replace to its owner, and give what they create in the home directory to the owner of the home directory.
300
359
  - A key `apply --user` writes into a user file can be overridden by a file the tool reads ahead of it: pnpm 10 reads `~/.npmrc` before its `rc` file. `apply --user` warns about a different value there (`user-setting-overridden`) with the line, and `doctor --user` reports it.
301
360
  - A tool's own project settings win over its user file for the same key. Parts of the policy that a tool reads only from project files, such as per-package allow-lists, are not written into the user file; `apply --user` names them, and `depcurfew apply` in the project writes them.
302
- - A user file is edited as `apply` edits a project file: a setting the policy manages that is set by hand elsewhere in the file is commented out and comes back when the policy stops managing it (YAML files get the policy's value instead). That includes an exception list the policy leaves empty, the command sections of `pip.conf` such as `[install]`, and the `[pip]` table of `uv.toml`. Install-script allow-list entries that the policy does not allow are kept and reported; a longer delay that the policy shortens is reported.
303
- - `doctor --user` reports a user file that does not match the user policy, values in user files that weaken every project, and environment variables that make a tool read another file, no file or other values, such as a relative `XDG_CONFIG_HOME`. It reads values as each tool does: a switch that allows every install script is on for every value the tool does not read as off (`dangerously-allow-all-builds=1` in `~/.npmrc` for pnpm 10), and a merge key (`<<: *defaults`) counts in pnpm's `config.yaml`. A missing home directory, or a user file it cannot read, is an error rather than a clean report, and an installed tool too old for settings the user policy needs (npm 10 has no release-age delay and no install-script allow-list) is a warning that says what stays unprotected and which version to update to.
361
+ - A user file is edited as `apply` edits a project file: a setting the policy manages that is set by hand elsewhere in the file is commented out and comes back when the policy stops managing it (YAML files get the policy's value instead). That includes an exception list the policy leaves empty, the command sections of `pip.conf` such as `[install]`, and the `[pip]` table of `uv.toml`. pip takes section names as written, so `[Global]` and `[ global ]` are sections it ignores: `apply --user` writes its block into a real `[global]` and leaves them as they are, and `doctor --user` does not read their keys as global ones. Install-script allow-list entries that the policy does not allow are kept and reported; a longer delay that the policy shortens is reported.
362
+ - `doctor --user` reports a user file that does not match the user policy, values in user files that weaken every project, and environment variables that make a tool read another file, no file or other values, such as a relative `XDG_CONFIG_HOME`. It reads values as each tool does: a switch that allows every install script is on for every value the tool does not read as off (`dangerously-allow-all-builds=1` in `~/.npmrc` for pnpm 10), and a merge key (`<<: *defaults`) counts in pnpm's `config.yaml`. A `pip.conf` that has a section twice, or a key twice in one section, is an error, because pip stops on it; from pip 26.3 that includes two spellings of one key, such as `only_binary` next to `only-binary`, and `apply --user` comments out every hand-written spelling of a key it writes. A missing home directory, or a user file it cannot read, is an error rather than a clean report, and an installed tool too old for settings the user policy needs (npm 10 has no release-age delay and no install-script allow-list) is a warning that says what stays unprotected and which version to update to.
304
363
  - Tools that record the release-age delay in their lockfile may lock differently on a machine whose user file sets one than on a machine without it; set the same delay in the project with `depcurfew apply` so that every machine and CI agree.
305
364
 
306
365
  <!-- BEGIN GENERATED: user-config -->
@@ -309,10 +368,10 @@ depcurfew doctor --user
309
368
  | npm | Linux, macOS: `~/.npmrc`<br>Windows: `%HOME%\.npmrc` (when it is an absolute path), else `~\.npmrc` | `min-release-age` (11.10.0+), `min-release-age-exclude` (11.17.0+), `strict-allow-scripts` (11.16.0+), `allow-scripts` (11.16.0+), `allow-git` (11.10.0+), `allow-remote` (11.15.0+), `allow-file` (11.15.0+), `allow-directory` (11.15.0+) | no |
310
369
  | pnpm | Linux: `$XDG_CONFIG_HOME/pnpm/rc`, else `~/.config/pnpm/rc`<br>macOS: `$XDG_CONFIG_HOME/pnpm/rc`, else `~/Library/Preferences/pnpm/rc`<br>Windows: `%XDG_CONFIG_HOME%\pnpm\rc`, else `%LOCALAPPDATA%\pnpm\config\rc`, else `~\.config\pnpm\rc`<br>11.0.0+: Linux: `$XDG_CONFIG_HOME/pnpm/config.yaml`, else `~/.config/pnpm/config.yaml`<br>macOS: `$XDG_CONFIG_HOME/pnpm/config.yaml`, else `~/Library/Preferences/pnpm/config.yaml`<br>Windows: `%XDG_CONFIG_HOME%\pnpm\config.yaml`, else `%LOCALAPPDATA%\pnpm\config\config.yaml`, else `~\.config\pnpm\config.yaml` | `minimum-release-age` (10.16.0–11.0.0), `minimum-release-age-exclude` (10.16.0–11.0.0), `only-built-dependencies` (10.0.0–11.0.0), `strict-dep-builds` (10.3.0–11.0.0), `block-exotic-subdeps` (10.26.0–11.0.0), `minimumReleaseAge` (11.0.0+), `minimumReleaseAgeExclude` (11.0.0+), `minimumReleaseAgeStrict` (11.0.0+), `strictDepBuilds` (11.0.7+), `blockExoticSubdeps` (11.0.0–12.0.0) | yes |
311
370
  | Yarn (Berry) | Linux, macOS, Windows: `~/.yarnrc.yml` | `npmMinimalAgeGate` (4.10.2+), `npmPreapprovedPackages` (4.10.0+), `enableScripts` (4.0.0+), `approvedGitRepositories` (4.14.0+) | yes |
312
- | Bun | Linux, macOS, Windows: `$XDG_CONFIG_HOME/.bunfig.toml` (when it is an absolute path), else `~/.bunfig.toml` | `install.minimumReleaseAge` (1.3.0+), `install.minimumReleaseAgeExcludes` (1.3.0+) | yes |
371
+ | Bun | Linux, macOS, Windows: `$XDG_CONFIG_HOME/.bunfig.toml` (when it is set, also to an empty value), else `~/.bunfig.toml`<br>1.4.4+: Linux, macOS, Windows: `$XDG_CONFIG_HOME/.bunfig.toml` (when it is an absolute path), else `~/.bunfig.toml` | `install.minimumReleaseAge` (1.3.0+), `install.minimumReleaseAgeExcludes` (1.3.0+) | yes |
313
372
  | Deno | 1.45.0+: Linux, macOS, Windows: `~/.npmrc` | `min-release-age` (2.8.0+) | no |
314
373
  | uv | Linux, macOS: `$XDG_CONFIG_HOME/uv/uv.toml` (when it is an absolute path), else `~/.config/uv/uv.toml`<br>Windows: `%APPDATA%\uv\uv.toml`, else `~\AppData\Roaming\uv\uv.toml` | `exclude-newer` (0.9.17+), `exclude-newer-package` (0.9.25+), `no-build` (0.2.12+), `no-build-package` (0.2.12+), `no-binary-package` (0.2.12+) | yes |
315
- | pip | Linux: `$XDG_CONFIG_HOME/pip/pip.conf`, else `~/.config/pip/pip.conf`<br>macOS: `~/Library/Application Support/pip/pip.conf` (when the folder exists), else `~/.config/pip/pip.conf`<br>Windows: `%APPDATA%\pip\pip.ini`, else `~\AppData\Roaming\pip\pip.ini`<br>26.2.0+: Linux: `$XDG_CONFIG_HOME/pip/pip.conf`, else `~/.config/pip/pip.conf`<br>macOS: `$XDG_DATA_HOME/pip/pip.conf` (when the folder exists), else `~/Library/Application Support/pip/pip.conf` (when the folder exists and $XDG_DATA_HOME is not set), else `~/.config/pip/pip.conf`<br>Windows: `%APPDATA%\pip\pip.ini`, else `~\AppData\Roaming\pip\pip.ini` | `uploaded-prior-to` (26.1.0+), `only-binary` (7.1.0+), `no-binary` (7.1.0+) | yes |
374
+ | pip | Linux: `$XDG_CONFIG_HOME/pip/pip.conf`, else `~/.config/pip/pip.conf`<br>macOS: `~/Library/Application Support/pip/pip.conf` (when the folder exists), else `~/.config/pip/pip.conf`<br>Windows: `%APPDATA%\pip\pip.ini`, else `~\AppData\Roaming\pip\pip.ini`<br>26.2.0+: Linux: `$XDG_CONFIG_HOME/pip/pip.conf`, else `~/.config/pip/pip.conf`<br>macOS: `$XDG_DATA_HOME/pip/pip.conf` (when the folder exists), else `~/Library/Application Support/pip/pip.conf` (when the folder exists and $XDG_DATA_HOME is not set), else `~/.config/pip/pip.conf`<br>Windows: `%APPDATA%\pip\pip.ini`, else `~\AppData\Roaming\pip\pip.ini`<br>26.3.0+: Linux: `$XDG_CONFIG_HOME/pip/pip.conf` (when it is an absolute path), else `~/.config/pip/pip.conf`<br>macOS: `$XDG_DATA_HOME/pip/pip.conf` (when it is an absolute path and the folder exists), else `~/Library/Application Support/pip/pip.conf` (when the folder exists and $XDG_DATA_HOME is not an absolute path), else `~/.config/pip/pip.conf`<br>Windows: `%APPDATA%\pip\pip.ini`, else `~\AppData\Roaming\pip\pip.ini` | `uploaded-prior-to` (26.1.0+), `only-binary` (7.1.0+), `no-binary` (7.1.0+) | yes |
316
375
  | Poetry | 2.0.0+: Linux: `$XDG_CONFIG_HOME/pypoetry/config.toml`, else `~/.config/pypoetry/config.toml`<br>macOS: `~/Library/Application Support/pypoetry/config.toml`<br>Windows: `%APPDATA%\pypoetry\config.toml`, else `~\AppData\Roaming\pypoetry\config.toml`<br>macOS with platformdirs before 4.6.0: `~/Library/Application Support/pypoetry/config.toml`<br>macOS with platformdirs 4.6.0–4.9.6: `$XDG_CONFIG_HOME/pypoetry/config.toml`, else `$XDG_DATA_HOME/pypoetry/config.toml`, else `~/Library/Application Support/pypoetry/config.toml`<br>macOS with platformdirs 4.9.6–4.11.8: `$XDG_CONFIG_HOME/pypoetry/config.toml`, else `~/Library/Application Support/pypoetry/config.toml`<br>macOS with platformdirs 4.11.8+: `$XDG_CONFIG_HOME/pypoetry/config.toml` (when it is an absolute path), else `~/Library/Application Support/pypoetry/config.toml` | `solver.min-release-age` (2.4.0+), `solver.min-release-age-exclude` (2.4.0+), `installer.only-binary` (2.0.0+), `installer.no-binary` (1.2.0+) | no |
317
376
  | PDM | Linux: `$XDG_CONFIG_HOME/pdm/config.toml`, else `~/.config/pdm/config.toml`<br>macOS: `~/Library/Application Support/pdm/config.toml`<br>Windows: `~\AppData\Local\pdm\pdm\config.toml`<br>macOS with platformdirs before 4.6.0: `~/Library/Application Support/pdm/config.toml`<br>macOS with platformdirs 4.6.0–4.9.6: `$XDG_CONFIG_HOME/pdm/config.toml`, else `$XDG_DATA_HOME/pdm/config.toml`, else `~/Library/Application Support/pdm/config.toml`<br>macOS with platformdirs 4.9.6–4.11.8: `$XDG_CONFIG_HOME/pdm/config.toml`, else `~/Library/Application Support/pdm/config.toml`<br>macOS with platformdirs 4.11.8+: `$XDG_CONFIG_HOME/pdm/config.toml` (when it is an absolute path), else `~/Library/Application Support/pdm/config.toml` | `strategy.exclude-newer` (2.27.0+) | yes |
318
377
  | Pipenv | none | none | no |
@@ -333,7 +392,7 @@ Fix: Fix what the message lists, or write a new user policy with depcurfew init
333
392
 
334
393
  **error** · A user-level configuration file cannot be read, parsed or edited, or the home directory does not exist, so depcurfew cannot check it.
335
394
 
336
- The package manager may still read the file (or stop on it), and its settings may weaken every project on the machine; doctor --user fails closed and reports an error instead of passing silently. Without a home directory the user files of most tools cannot be found at all.
395
+ The package manager may still read the file (or stop on it), and its settings may weaken every project on the machine; doctor --user fails closed and reports an error instead of passing silently. A pip.conf that has a section twice, or a key twice in one section (from pip 26.3 also only_binary next to only-binary), stops pip itself. Without a home directory the user files of most tools cannot be found at all.
337
396
 
338
397
  Fix: Do what the fix of the finding says: fix invalid syntax, give read permission, replace a symlink apply --user does not write through, make a file smaller than 1 MiB, or set HOME to an existing directory.
339
398
 
@@ -369,26 +428,40 @@ What depcurfew configures and checks for each tool, from its [knowledge base](ht
369
428
  <!-- BEGIN GENERATED: tools -->
370
429
  | Tool | Release-age delay | Exceptions | Install-script allow-list (Python: packages built from source; Composer: plugins) | Dependency sources | Verified |
371
430
  | --- | --- | --- | --- | --- | --- |
372
- | npm | `min-release-age` in `.npmrc` (days), since 11.10.0 | `min-release-age-exclude`, since 11.17.0 | `allowScripts` in `package.json` | git: `allow-git`<br>remote tarball: `allow-remote`<br>local tarball: `allow-file`<br>local directory: `allow-directory` | 2026-10-28 – 2026-11-02 |
373
- | pnpm | `minimumReleaseAge` in `pnpm-workspace.yaml` (minutes), since 10.16.0 | `minimumReleaseAgeExclude`, since 10.16.0 | `allowBuilds` in `pnpm-workspace.yaml` | git: `blockExoticSubdeps`<br>remote tarball: `blockExoticSubdeps` | 2026-10-28 – 2026-11-02 |
374
- | Yarn (Berry) | `npmMinimalAgeGate` in `.yarnrc.yml` (minutes), since 4.10.2 | `npmPreapprovedPackages`, since 4.10.0 | `dependenciesMeta.<package>.built` in `package.json` | git: `approvedGitRepositories` | 2026-10-28 – 2026-11-02 |
375
- | Bun | `install.minimumReleaseAge` in `bunfig.toml` (seconds), since 1.3.0 | `install.minimumReleaseAgeExcludes`, since 1.3.0 | `trustedDependencies` in `package.json` | none | 2026-10-28 – 2026-11-02 |
376
- | Deno | `minimumDependencyAge` in `deno.json`, `.npmrc` (minutes), since 2.5.5 / 2.6.0 ⚠️ | `minimumDependencyAge.exclude`, since 2.5.5 | `allowScripts` in `deno.json` | none | 2026-10-28 – 2026-11-02 |
377
- | uv | `exclude-newer` in `uv.toml`, `pyproject.toml` (duration string), since 0.9.17 | `exclude-newer-package`, since 0.9.25 | `no-binary-package` in `pyproject.toml` | none | 2026-10-30 – 2026-11-02 |
378
- | pip | `uploaded-prior-to` on the command line or in the environment (no project file) (days), since 26.1.0 | none | `--no-binary` in `requirements.txt` | none | 2026-10-30 – 2026-11-02 |
379
- | Poetry | `solver.min-release-age` in `poetry.toml` (days), since 2.4.0 | `solver.min-release-age-exclude`, since 2.4.0 | `installer.no-binary` in `poetry.toml` | none | 2026-10-30 – 2026-11-02 |
380
- | PDM | `exclude-newer` in `pyproject.toml` (duration string), since 2.26.9 | `exclude-newer-override`, since 2.29.1 | `no-binary` in `pyproject.toml` | none | 2026-10-30 – 2026-11-02 |
381
- | Pipenv | `cool-down-period` in `Pipfile` (duration string), since 2026.6.2 | none | none | none | 2026-10-30 – 2026-11-02 |
382
- | Composer | `config.policy.cooldown.period` in `composer.json` (duration string), since 2.11.0 | `config.policy.cooldown.ignore`, since 2.11.0 | `config.allow-plugins` in `composer.json` | none | 2026-11-02 |
383
- | Renovate | `minimumReleaseAge` in `renovate.json`, `renovate.jsonc` … (duration string), since 35.46.0 | `packageRules: matchPackageNames + minimumReleaseAge: null`, since 35.46.0 ❓ | none | n/a | 2026-10-28 – 2026-11-02 |
384
- | Dependabot | `cooldown` in `.github/dependabot.yml`, `.github/dependabot.yaml` (days), since 2025 07 01 | `cooldown.exclude`, since 2025 07 01 ❓ | none | n/a | 2026-10-28 – 2026-11-02 |
431
+ | npm | `min-release-age` in `.npmrc` (days), since 11.10.0 | `min-release-age-exclude`, since 11.17.0 | `allowScripts` in `package.json` | git: `allow-git`<br>remote tarball: `allow-remote`<br>local tarball: `allow-file`<br>local directory: `allow-directory` | 2026-10-28 – 2026-11-03 |
432
+ | pnpm | `minimumReleaseAge` in `pnpm-workspace.yaml` (minutes), since 10.16.0 | `minimumReleaseAgeExclude`, since 10.16.0 | `allowBuilds` in `pnpm-workspace.yaml` | git: `blockExoticSubdeps`<br>remote tarball: `blockExoticSubdeps` | 2026-10-28 – 2026-11-03 |
433
+ | Yarn (Berry) | `npmMinimalAgeGate` in `.yarnrc.yml` (minutes), since 4.10.2 | `npmPreapprovedPackages`, since 4.10.0 | `dependenciesMeta.<package>.built` in `package.json` | git: `approvedGitRepositories` | 2026-10-28 – 2026-11-03 |
434
+ | Bun | `install.minimumReleaseAge` in `bunfig.toml` (seconds), since 1.3.0 | `install.minimumReleaseAgeExcludes`, since 1.3.0 | `trustedDependencies` in `package.json` | none | 2026-10-28 – 2026-11-03 |
435
+ | Deno | `minimumDependencyAge` in `deno.json` (minutes) or `min-release-age` in `.npmrc` (days), since 2.5.5 / 2.6.0 ⚠️ | `minimumDependencyAge.exclude`, since 2.5.5 | `allowScripts` in `deno.json` | none | 2026-10-28 – 2026-11-03 |
436
+ | uv | `exclude-newer` in `uv.toml`, `pyproject.toml` (duration string), since 0.9.17 | `exclude-newer-package`, since 0.9.25 | `no-binary-package` in `pyproject.toml` | none | 2026-10-30 – 2026-11-03 |
437
+ | pip | `uploaded-prior-to` (days) on the command line or in the environment, no project file, since 26.1.0 | none | `--no-binary` in `requirements.txt` | none | 2026-10-30 – 2026-11-03 |
438
+ | Poetry | `solver.min-release-age` in `poetry.toml` (days), since 2.4.0 | `solver.min-release-age-exclude`, since 2.4.0 | `installer.no-binary` in `poetry.toml` | none | 2026-10-30 – 2026-11-03 |
439
+ | PDM | `exclude-newer` in `pyproject.toml` (duration string), since 2.26.9 | `exclude-newer-override`, since 2.29.1 | `no-binary` in `pyproject.toml` | none | 2026-10-30 – 2026-11-03 |
440
+ | Pipenv | `cool-down-period` in `Pipfile` (duration string), since 2026.6.2 | none | none | none | 2026-10-30 – 2026-11-03 |
441
+ | Composer | `config.policy.cooldown.period` in `composer.json` (duration string), since 2.11.0 | `config.policy.cooldown.ignore`, since 2.11.0 | `config.allow-plugins` in `composer.json` | none | 2026-11-02 – 2026-11-03 |
442
+ | Bundler | `cooldown` in `Gemfile`, `gems.rb` (days), since 4.0.13 | none | none | none | 2026-11-03 |
443
+ | Renovate | `minimumReleaseAge` in `renovate.json`, `renovate.jsonc` … (duration string), since 35.46.0 | `packageRules: matchPackageNames + minimumReleaseAge: null`, since 35.46.0 ❓ | none | n/a | 2026-10-28 – 2026-11-03 |
444
+ | Dependabot | `cooldown` in `.github/dependabot.yml`, `.github/dependabot.yaml` (days), since 2025-07-01 | `cooldown.exclude`, since 2025-07-01 ❓ | none | n/a | 2026-10-28 – 2026-11-03 |
445
+ | Cargo (bots only) | `registry.global-min-publish-age` in `.cargo/config.toml`, since Cargo 1.100.0 (depcurfew does not write it) ❓; the delay comes from the bots: Dependabot `cargo`, Renovate `cargo` | n/a | n/a | n/a | 2026-11-03 |
446
+ | Go modules (bots only) | none ❓; the delay comes from the bots: Dependabot `gomod`, Renovate `gomod` | n/a | n/a | n/a | 2026-11-03 |
447
+ | Maven (bots only) | none ❓; the delay comes from the bots: Dependabot `maven`, Renovate `maven` | n/a | n/a | n/a | 2026-11-03 |
448
+ | Gradle (bots only) | none ❓; the delay comes from the bots: Dependabot `gradle`, Renovate `gradle` | n/a | n/a | n/a | 2026-11-03 |
449
+ | NuGet (bots only) | `minPublishAgeHours` in `nuget.config`, since .NET SDK 11.0.100, for updates only (depcurfew does not write it) ❓; the delay comes from the bots: Dependabot `nuget`, Renovate `nuget` | n/a | n/a | n/a | 2026-11-03 |
385
450
 
386
451
  ⚠️ official sources disagree (depcurfew takes the safer reading) · ❓ official sources are incomplete. Every value with its source: [docs/knowledge.md](https://github.com/DevXord/depcurfew/blob/main/docs/knowledge.md).
387
452
  <!-- END GENERATED: tools -->
388
453
 
389
- Yarn Classic (1.x) is detected but not supported.
454
+ Yarn Classic (1.x) is detected but not supported: `apply` writes nothing for it. `doctor` still looks up the versions its `yarn.lock` locks for known malicious versions (`known-malicious`, with `--osv` or the local data of `depcurfew osv update`): the registry tarball of each entry's `resolved`, which Yarn Classic fetches, read as the parser of Yarn 1.22 reads the file.
455
+
456
+ **The Yarn that runs.** Every Yarn from 1.18 runs the release that `yarnPath` in `.yarnrc.yml` names instead of itself, with or without Corepack, so a `yarnPath` to a release in the repository named `yarn-<version>.cjs`, as `yarn set version` names it, is the version of the project and wins over `packageManager`. `apply` writes only the settings that release supports, so a Yarn 3 project gets no release-age delay and no allow-list of git repositories, settings Yarn 3.6.4 would stop at as unknown, and its `version-too-old` points to `yarnPath` and names `yarn set version`. `doctor` checks that release (`manager-version-too-old` and the other rules), and `setting-ignored-by-version` warns when `packageManager` pins another release, which then has no effect. A `yarnPath` whose file name gives no version, a path outside the repository or a missing file leaves the version as before: `packageManager`, or unknown.
457
+
458
+ **pnpm: `include`, pnpmfiles and plugins.** From pnpm 12.15, `include` in `pnpm-workspace.yaml` can give settings to local YAML files and to config dependencies, each setting with one owner. depcurfew reads every setting where pnpm takes it from: `apply` writes it into the file that owns it (the file whose list names it, also when the file does not set it yet; a `'*'` file that sets it; otherwise `pnpm-workspace.yaml`) and never into a second place, which pnpm rejects with `ERR_PNPM_DUPLICATE_SETTING`, and `doctor` reports a weakening setting in such a file with its file and line. A setting a config dependency owns (or a file outside the directory) can be neither read nor written: `apply` says so (`cooldown-not-written`, `setting-owned-elsewhere`) and `doctor` warns (`pnpm-config-unverifiable`), as it does for an `include` pnpm rejects. The `updateConfig` hook of a pnpmfile (`.pnpmfile.mjs`, `.pnpmfile.cjs`, or the files the `pnpmfile` and `globalPnpmfile` settings name) and of the pnpmfile pnpm loads by itself from a config dependency named `pnpm-plugin-*`, `@*/pnpm-plugin-*` or `@pnpm/plugin-*` can change every setting after pnpm has read its files. depcurfew does not run them, so `doctor` warns about each one (an error for a plugin the knowledge base knows to set a shorter delay than the policy, such as `@pnpm/plugin-better-defaults` 0.3.0) and `explain` lists them (`pnpm-hook`). `ignoreScripts` does not stop them; `ignorePnpmfile: true` does, and `pnpmfile: []` keeps pnpm from loading a new `.pnpmfile.mjs` by itself. Versions before 12.15 read `pnpm-workspace.yaml` alone, as before.
459
+
460
+ **Tarballs of private npm registries.** In `package-lock.json`, a tarball URL counts as a registry package (for `lockfile-forbidden-source` and `known-malicious`) when it is in npm's layout, in the layout of GitHub Packages (`https://npm.pkg.github.com/download/@scope/name/<version>/<digest>`) or of GitLab and Artifactory (`…/@scope/name/-/@scope/name-<version>.tgz`), or below the registry that the `.npmrc` next to `package-lock.json` sets for the package, which npm 12 installs as a registry package too; a dependency that asks for it by URL makes it a tarball URL all the same. A tarball URL in another layout that `package.json` asks for by version stays an error, and its fix says to set that registry in `.npmrc`, not to allow tarball URLs.
390
461
 
391
- **Where Node.js and Python come from.** `doctor` also reads the files that choose the runtime: `.nvmrc`, `.node-version`, `engines.node`, Volta, `.tool-versions`, `devEngines.runtime`, `actions/setup-node` in workflows (with `node-version-file` and matrix values), and Dockerfiles (`Dockerfile`, `*.Dockerfile`, `Dockerfile.<name>` and `Containerfile` in the root, in direct subdirectories without a manifest of their own and in workspace packages, with `FROM node:<tag>` or `FROM python:<tag>`), and the image of a job container (`container: node:20`, read like a `FROM`). An unpinned npm is the one that comes with that Node.js: Node.js 22 comes with npm 10, which ignores the release-age setting without a warning. With `installScripts.default: "deny"`, `apply` writes `strict-allow-scripts=true` when any of these places runs npm 11.16 to 11.x (the pinned npm, an npm a CI job or Dockerfile installs, or the npm of a declared Node.js), also when another place runs npm 12, and says what that means for npm 12; `doctor` offers the setting with "which depcurfew apply writes" only where `apply` does. `manager-version-too-old` reports such a runtime where its npm (or the pip of the official `python` image) lacks a setting in use, unless the CI job or the build stage installs another one first (`npm install -g npm@<version>`, `pip install pip==<version>`), which is then checked the same way: a range (`npm@^11.10.0`) by its lowest version, and a version depcurfew cannot read (`npm@latest`, `npm@${NPM_VERSION}`) counts as an npm that runs every dependency install script. A place whose installs are all `npm ci` needs no release-age setting, because `npm ci` installs exactly the versions of `package-lock.json`; one whose installs run with scripts off (`--ignore-scripts`, `npm_config_ignore_scripts=true` in `env:`, `ENV` or on the command) needs no script blocking and counts for no npm that runs scripts. When the npm a fix names does not run on the Node.js of the place (npm 11 needs Node.js `^20.17.0 || >=22.9.0`), the fix says to move to such a Node.js first. A `pip install` in a CI job or in a stage of the `python` image needs `PIP_UPLOADED_PRIOR_TO` (in `env:`, `ENV` or on the command) or `--uploaded-prior-to` with at least the days of the policy, because pip reads no configuration from the project; a job or stage that sets `PIP_CONFIG_FILE`, runs `pip config set` or writes a `pip.conf` is not checked. A stage whose image tag does not tell its Node.js or Python (a digest alone, an unknown tag) gives a note with a fix. `runtime-end-of-life` reports Node.js lines past their end of life. `corepack-unavailable` reports a `packageManager` or `devEngines.packageManager` pin of Yarn or npm, which only Corepack reads, where a declared Node.js is 25 or later: Node.js 25.0.0 stopped distributing Corepack, so the pin has no effect there until Corepack is installed (`npm install -g corepack`, then `corepack enable`, or `corepack enable npm` for npm). pnpm 10 and later switches to its pinned version itself and npm checks `devEngines.packageManager` itself, so those pins are not reported, and neither is a CI job or Dockerfile stage that installs or runs Corepack, or installs the pinned npm release itself (`npm install -g npm@<version>`), before it installs with the pinned tool; one that installs another npm release first gets the warning with that release named. `depcurfew explain` lists what it found.
462
+ **Where Node.js and Python come from.** `doctor` also reads the files that choose the runtime: `.nvmrc`, `.node-version`, `engines.node`, Volta, `.tool-versions`, `devEngines.runtime`, `[tools]` of `mise.toml` (also `.mise.toml`, `mise/config.toml`, `.mise/config.toml`, `.config/mise.toml`, `.config/mise/mise.toml` and `.config/mise/config.toml`, the first file that sets a tool winning, as mise reads them; a list gives its first entry, a table its `version`), `actions/setup-node` in workflows (with `node-version-file`, in whose `mise.toml` it reads `tools.node`, and matrix values, also inside more text, `${{ matrix.node }}.x`, or as a property of the matrix objects, `${{ matrix.cfg.node }}`, with the values `include` adds and those `exclude` removes), `jdx/mise-action` (the Node.js of the mise configuration of its working directory or of its `mise_toml` input, unless `install: false`), and Dockerfiles (`Dockerfile`, `*.Dockerfile`, `Dockerfile.<name>` and `Containerfile` in the root, in direct subdirectories without a manifest of their own and in workspace packages, with `FROM node:<tag>` or `FROM python:<tag>`), and the image of a job container (`container: node:20`, read like a `FROM`). An unpinned npm is the one that comes with that Node.js: Node.js 22 comes with npm 10, which ignores the release-age setting without a warning. With `installScripts.default: "deny"`, `apply` writes `strict-allow-scripts=true` when any of these places runs npm 11.16 to 11.x (the pinned npm, an npm a CI job or Dockerfile installs, or the npm of a declared Node.js), also when another place runs npm 12, and says what that means for npm 12; `doctor` offers the setting with "which depcurfew apply writes" only where `apply` does. `manager-version-too-old` reports such a runtime where its npm (or the pip of the official `python` image) lacks a setting in use, unless the CI job or the build stage installs another one first (`npm install -g npm@<version>`, `pip install pip==<version>`), which is then checked the same way. npm installs the newest release of a range that runs on the Node.js there, so a range (`npm@11`, `npm@^11.10.0`), `npm@latest` and a bare `npm` count as the newest such release depcurfew knows (a warning when it lacks a setting, since a newer release may be out), never as the lowest version of the range; `npm@${NPM_VERSION}` takes the value of `NPM_VERSION` in the `env` of the workflow, the job and the step, or in the `ENV` and `ARG` of the stage; a spec depcurfew still cannot read (`npm@next`, a variable without a value, a range with none of the releases it knows) counts as an npm that runs every dependency install script. `apply` names the places that run an npm without a setting it writes for the release-age delay or the dependency sources (`version-in-use-too-old`), from the same list as `doctor`: `.nvmrc` with `24` can come with npm 11.3.0, which has no release-age delay. A place whose installs are all `npm ci` needs no release-age setting, because `npm ci` installs exactly the versions of `package-lock.json`; one whose installs run with scripts off (`--ignore-scripts`, `npm_config_ignore_scripts=true` in `env:`, `ENV` or on the command) needs no script blocking and counts for no npm that runs scripts. When the npm a fix names does not run on the Node.js of the place (npm 11 needs Node.js `^20.17.0 || >=22.9.0`), the fix says to move to such a Node.js first. A `pip install` in a CI job or in a stage of the `python` image needs `PIP_UPLOADED_PRIOR_TO` (in `env:`, `ENV` or on the command) or `--uploaded-prior-to` with at least the days of the policy, because pip reads no configuration from the project; a job or stage that sets `PIP_CONFIG_FILE`, runs `pip config set` or writes a `pip.conf` is not checked. A `pip.conf` of the repository that `PIP_CONFIG_FILE` names is read where it does, so `python-config-ignored` does not report it: in a CI job as its path from the workspace (`pip.conf`, `${{ github.workspace }}/pip.conf`), in a Dockerfile stage as a path with its name in a stage that copies it (`COPY pip.conf ./` with `ENV PIP_CONFIG_FILE=/app/pip.conf`). A stage whose image tag does not tell its Node.js or Python (a digest alone, an unknown tag) gives a note with a fix. `runtime-end-of-life` reports Node.js lines past their end of life. `corepack-unavailable` reports a `packageManager` or `devEngines.packageManager` pin of Yarn or npm, which only Corepack reads, where a declared Node.js is 25 or later: Node.js 25.0.0 stopped distributing Corepack, so the pin has no effect there until Corepack is installed (`npm install -g corepack`, then `corepack enable`, or `corepack enable npm` for npm). pnpm 10 and later switches to its pinned version itself and npm checks `devEngines.packageManager` itself, so those pins are not reported, and neither is a CI job or Dockerfile stage that installs or runs Corepack, or installs the pinned npm release itself (`npm install -g npm@<version>`), before it installs with the pinned tool; one that installs another npm release first gets the warning with that release named. `depcurfew explain` lists what it found.
463
+
464
+ **Settings that CI and Dockerfiles turn off.** Package managers take settings from the command line and from environment variables over their configuration files, so a workflow or a Dockerfile can undo what `apply` wrote for every install it runs. The rule `ci-weakens-policy` reads the variables of the `env` of a workflow, a job, its container and a step, `export NAME=value` and `NAME=value` before a command in a `run` step, `NAME=value` lines a step writes to `$GITHUB_ENV` with `echo` or `printf` (which the later steps of the job get, over the `env` of the workflow and the job and under that of a step), `ENV` and the defaults of `ARG` in a Dockerfile stage (an `ARG` before the first `FROM` only where a stage declares it again), and the options on the commands of `run` steps and `RUN` instructions, for npm, pnpm, Yarn, Bun, Deno, uv, Poetry and PDM: `NPM_CONFIG_MIN_RELEASE_AGE=0` (in any case), `npm_config_strict_allow_scripts=false` where `.npmrc` sets it, `npm_config_allow_git=all` or `root` while the policy denies git sources, `pnpm_config_minimum_release_age=0`, `YARN_ENABLE_SCRIPTS=true`, `UV_EXCLUDE_NEWER` with a shorter delay, `POETRY_SOLVER_MIN_RELEASE_AGE=0`, and `npm install --min-release-age=0 --allow-git=all --no-strict-allow-scripts`, `pnpm install --config.minimum-release-age=0 --config.dangerously-allow-all-builds=true`, `uv lock --exclude-newer "1 hour"` or `pdm lock --exclude-newer 1h`. Each one is an error at its line, with a fix; a value with `$` or `${{ }}`, and a delay depcurfew cannot compare (a date), give a warning. The options and variables come from the knowledge base (those the install guard asks about, the option of each tool's delay, and for npm the settings of install scripts and sources it writes into `.npmrc`); `npm_config_ignore_scripts=false` and `--ignore-scripts=false` are not reported, since `apply` never writes `ignore-scripts`. pip has `python-config-ignored`, Composer `composer-config-weakens` and Bundler `bundler-config-weakens`, which read the same places. Not read: a `run` step in PowerShell (`$env:NAME`), `{ ...; } >> "$GITHUB_ENV"` and multiline values (`NAME<<EOF`), `npm config set`, `pnpm config set`, `yarn config set`, `poetry config` and `pdm config` in CI, abbreviated options (`--min-rel=0`) and the commands of a script that `sh -c` runs.
392
465
 
393
466
  ### Python projects
394
467
 
@@ -397,9 +470,9 @@ depcurfew configures uv, Poetry, PDM, Pipenv and pip from the same `depcurfew.js
397
470
  - **Detection.** A Python tool counts as used when its lockfile is there, when `pyproject.toml` has its table, or when its own configuration file or a CI setup step shows it; `requirements.txt` means pip. `depcurfew explain` lists what it found.
398
471
  - **Package names.** Python packages on the policy's package lists (the cooldown exceptions, `installScripts.allow` and `installScripts.deny`) take the prefix `pypi:`, for example `"pypi:docopt"`. Names without a prefix stay npm names, so an exception for an npm package never applies to a Python package of the same name.
399
472
  - **Install scripts are builds.** A wheel installs without running package code; a source distribution, a git repository or a local directory is built, which runs its build code. `installScripts.default: "deny"` forbids building, and the packages in `installScripts.allow` are the exceptions. None of the three tools can only allow a build, so these packages are always built from source, even where a wheel exists; `apply` says so, and `doctor` lists the packages in the lockfile that would be built. Packages in `installScripts.deny` must not be built: `doctor` reports one that a tool would still build (Poetry, PDM, pip and Pipenv build git, local and URL dependencies whatever their settings say), and one without wheels that cannot be installed while building is blocked.
400
- - **pip reads no project configuration.** `apply` writes the build options into `requirements.txt`. The release-age delay cannot live in the project, so `apply` prints the environment variable to set where pip runs. `doctor` also checks the files a requirements file includes with `-r` and `-c`, and reports an include it cannot read.
473
+ - **pip reads no project configuration.** `apply` writes the build options into `requirements.txt`. The release-age delay cannot live in the project, so `apply` prints the environment variable to set where pip runs. pip reads the options of the files a requirements file includes with `-r` and `-c`, and the options after a requirement on its line, into the same install, so an option there that makes pip build packages from source again (a package name or `:all:` for building from source, `--only-binary=:none:`) undoes `--only-binary=:all:`: `apply` comments such option lines out in the included files inside the repository (they come back when the policy stops forbidding builds), and warns with the file and line where it cannot (`build-switch-undone` for an option after a requirement or next to other options, `requirements-include-unread` for a URL, a path outside the repository or a missing file). `doctor` reports them (`config-drift`, and `unreadable-config` for an include it cannot read), and follows the options in pip's order when it decides whether building is forbidden.
401
474
  - **Lock again after `apply`.** `uv.lock` records the release-age delay, and PDM checks `pdm.lock` against all of its resolution settings, the delay and the build ban included. After `apply` changes them, `uv sync --locked` and `uv lock --check` fail until you run `uv lock`, and `pdm lock --check` and `pdm install --check` until you run `pdm lock`; `apply` says so (`lockfile-out-of-date`), and so does `doctor` (the rule `lockfile-out-of-date`) while the lockfile is not locked again: it compares what `uv.lock` records with the settings as uv does, and the hash in `pdm.lock` with the one PDM computes from `pyproject.toml`. `poetry.lock` and `Pipfile.lock` do not depend on the settings `apply` writes.
402
- - **PDM and Pipenv cannot pin their own version.** `apply` configures for the latest version depcurfew knows and names that version (`version-not-pinned`). Pipenv takes the delay in whole days only (rounded up), has no exceptions and no project setting that forbids building; `apply` says what to set where Pipenv runs instead. `doctor` reads `pdm.lock` and `Pipfile.lock`, and reports settings that turn the delay or the build ban off without a word: Pipenv's pure-Python resolver and PDM's uv mode.
475
+ - **PDM and Pipenv have no setting that pins their own version.** Where nothing pins it, `apply` configures for the latest version depcurfew knows and names that version (`version-not-pinned`). What CI jobs and Dockerfiles install counts as a version, and `doctor` checks it (`manager-version-too-old`): the `version` input of `pdm-project/setup-pdm` (`head` names none); `pdm==<version>`, `pipenv==<version>`, `uv==<version>` and `poetry==<version>` in `pip install`, `pipx install` or `uv tool install`, with a variable read from the `env` of the job or the `ENV` and `ARG` of the stage (`pip install "poetry==${POETRY_VERSION}"`); and the uv of the official uv image a Dockerfile copies from or starts from (`COPY --from=ghcr.io/astral-sh/uv:0.8.22 /uv /uvx /bin/`, `FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm`; `latest` and a base name alone name no version). Pipenv takes the delay in whole days only (rounded up), has no exceptions and no project setting that forbids building; `apply` says what to set where Pipenv runs instead. `doctor` reads `pdm.lock` and `Pipfile.lock`, and reports settings that turn the delay or the build ban off without a word: Pipenv's pure-Python resolver and PDM's uv mode.
403
476
  - **Hatch has no settings of its own.** Its environments install with uv or pip, so a Hatch project is covered by the uv configuration (add `"uv"` to `targets` when its environments use the uv installer) or by the environment variables of pip.
404
477
  - **Running depcurfew.** depcurfew is a Node.js program. In a project without `package.json`, run a pinned version with `npx --yes depcurfew@<version>`; in CI the [GitHub Action](#github-action) needs nothing else.
405
478
 
@@ -407,10 +480,11 @@ depcurfew configures uv, Poetry, PDM, Pipenv and pip from the same `depcurfew.js
407
480
 
408
481
  `apply` gives Renovate and Dependabot the policy's delay, rounded up to their unit and at least as long as the delay of every package manager after rounding, so that they never propose a version the package managers would refuse. It edits only bot configuration files that exist and adds no entries to them.
409
482
 
410
- - **Dependabot** gets `cooldown` in each `updates` entry of an ecosystem depcurfew configures (`npm`, `bun`, `deno`, `pip`, `uv`, `composer`), with the policy's exceptions for that registry under `exclude`.
483
+ - **Dependabot** gets `cooldown` in each `updates` entry of an ecosystem depcurfew configures (`npm`, `bun`, `deno`, `pip`, `uv`, `composer`, `bundler`), with the policy's exceptions for that registry under `exclude` (a `bundler` entry keeps its own, since the policy names no gems), and in the entries of the ecosystems updated by bots only (below). An `include` list in such a `cooldown` stays, but it limits the cooldown to the dependencies it names, so `apply` warns (`bot-rule-shortens-delay`, with the entry and the line) unless it is `"*"`; so it does when an entry keeps its own `exclude` (actions, gems, the ecosystems updated by bots only) and that list matches every dependency (`"*"`), since Dependabot then waits for none of them. `doctor` reports both as before (`bot-cooldown-mismatch`, `broad-exclusion`).
411
484
  - **GitHub Actions.** When the repository has workflows in `.github/workflows` and `targets` is `"auto"` or names the bot, the actions they run wait as long as the packages. The `github-actions` entries of Dependabot get the same `default-days` (GitHub Actions reads no `semver-*-days`), and their `exclude` stays as written, because the policy's exceptions name packages, not actions. The delay at the top level of the Renovate configuration covers its `github-actions` manager too. `doctor` (`bot-cooldown-mismatch`) compares the bot's delay for the actions with `cooldown.minimum`.
412
- - **Renovate** gets the delay at the top level of its configuration and a `packageRules` entry for the exceptions. `apply` and `doctor` read the other `packageRules` entries the way Renovate matches them, also by `matchManagers` and `matchCategories`: `npm` covers the packages of npm, pnpm and Yarn, `bun` and `deno` those of Bun and Deno, `pep621` those of uv and PDM, `poetry`, `pip_requirements` and `pipenv` those of Poetry, pip and Pipenv, `composer` those of Composer, and `github-actions` the actions of the workflows; the categories are `js`, `python`, `php` and `ci`. Globs, `/regex/`, `!` and a single string instead of a list work as in Renovate. A rule that shortens the delay of a package manager in use (or of the actions) gives `bot-rule-shortens-delay` in `apply` and `bot-cooldown-mismatch` in `doctor`, and one that turns it off gives `broad-exclusion`. For the actions, a rule with `matchFileNames` that matches every workflow file of the repository (read as Renovate reads it: `*` within one directory, `**` across them, any case, or `/regex/`) and a rule with `matchDepTypes` that matches `action`, the depType Renovate gives an action of a workflow step, count too; one that matches only some workflow files, or only reusable workflows (`workflow`), leaves the other actions their delay and does not count. A rule with a glob matcher depcurfew cannot read (braces, character classes, `?`, or extglobs such as `@(a|b)` and `+(…)`) does not count, and `doctor` gives a note when it may set a shorter delay; so does a rule with a matcher depcurfew does not evaluate (`matchBaseBranches`, `matchJsonata`, `matchRepositories`, `matchCurrentAge`, and `matchFileNames` or `matchDepTypes` for the packages of a package manager), unless a package matcher already limits it to some packages. When rules give the update types different delays, `doctor` reports each one that is shorter, not only the shortest.
413
- - **Numbers in `dependabot.yml`** are read as Dependabot's YAML library, Ruby's Psych, reads them, which follows YAML 1.1: `default-days: 010` is 8 days (octal), `1_0` and `1,0` are 10, `1:30` is 5400, and `0o12` is text. `doctor` compares the number Dependabot reads with the policy and says how it read it ("default-days: 010 in .github/dependabot.yml, which Dependabot reads as 8"), and `apply` writes the policy's number in place of one Dependabot reads otherwise.
485
+ - **Cargo, Go modules, Maven, Gradle and NuGet are updated by bots only.** Their package managers have no release-age delay that depcurfew configures (the rows "bots only" in [Supported tools](#supported-tools) say what each has), so depcurfew delays their new versions through the bots alone. When `depcurfew.json` has a `cooldown` and `targets` is `"auto"` or names the bot, the Dependabot entries `cargo`, `gomod`, `maven`, `gradle` and `nuget` get the same `default-days` as the packages, lower `semver-*-days` are raised to it, and their `exclude` stays as written, because the policy's exceptions name npm, Python and Composer packages. The delay at the top level of the Renovate configuration covers its `cargo`, `gomod`, `maven`, `gradle` and `nuget` managers; `packageRules` entries for them by manager, category (`rust`, `golang`, `java`, `dotnet`) or datasource (`crate`, `go`, `maven`, `nuget`) count as for the package managers, and the preset `security:minimumReleaseAgeCrate` gives Cargo dependencies 3 days. `doctor` (`bot-cooldown-mismatch`) compares the bot's delay for them with `cooldown.minimum`, and gives one note per ecosystem when the repository has their files but no update bot, since nothing delays their new versions then. depcurfew looks for these files (`Cargo.toml`, `go.mod`, `pom.xml`, `settings.gradle`, a `.csproj`…) in the root, in its direct subdirectories that are not hidden, in workspace packages and in the directories listed in `projects`; `depcurfew explain` lists what it found under "Updated by bots only". Other dependencies that come in with the bot's update of a lockfile (`Cargo.lock`, `go.sum`…) are not held back, because the package manager has no delay of its own to apply to them.
486
+ - **Renovate** gets the delay at the top level of its configuration and a `packageRules` entry for the exceptions of each registry, limited to its datasource: `matchDatasources: ["npm"]` for npm names, `["pypi"]` for `pypi:` names and `["packagist"]` for `composer:` names, so an exception never reaches a crate, a Go module, a gem or an action of the same name. A built-in preset of `extends` that gives a datasource in use a shorter delay than the policy (`config:best-practices` and `security:minimumReleaseAgeNpm` 3 days for `npm`, `security:minimumReleaseAgePypi` for `pypi`, `security:minimumReleaseAgeCrate` for `crate`) wins over the top-level value, since a matching rule wins over the top level; `apply` then writes a rule with that datasource, `matchUpdateTypes: ["major", "minor", "patch"]` and the policy's delay at the front of `packageRules`, which Renovate applies after the rules of the presets and before the repository's own rules and the exception rules, and removes it when the preset goes or no longer waits less. The deprecated `renovate` key of `package.json` and `.renovaterc` get it in the snippet. `doctor` reports a rule of the repository that gives npm, Python or Composer packages of some names a shorter delay than `cooldown.minimum`, or none, while the policy's exceptions do not exempt them (`broad-exclusion`, at the line of the rule), and a rule an earlier `apply` wrote without `matchDatasources: ["npm"]`. `apply` and `doctor` read the other `packageRules` entries the way Renovate matches them, also by `matchManagers` and `matchCategories`: `npm` covers the packages of npm, pnpm and Yarn, `bun` and `deno` those of Bun and Deno, `pep621` those of uv and PDM, `poetry`, `pip_requirements` and `pipenv` those of Poetry, pip and Pipenv, `composer` those of Composer, `bundler` those of Bundler, and `github-actions` the actions of the workflows; the categories are `js`, `python`, `php`, `ruby` and `ci`. Globs, `/regex/`, `!` and a single string instead of a list work as in Renovate. A rule that shortens the delay of a package manager in use (or of the actions) gives `bot-rule-shortens-delay` in `apply` and `bot-cooldown-mismatch` in `doctor`, and one that turns it off gives `broad-exclusion`. For the actions, a rule with `matchFileNames` that matches every workflow file of the repository (read as Renovate reads it: `*` within one directory, `**` across them, any case, or `/regex/`) and a rule with `matchDepTypes` that matches `action`, the depType Renovate gives an action of a workflow step, count too; one that matches only some workflow files, or only reusable workflows (`workflow`), leaves the other actions their delay and does not count. A rule with a glob matcher depcurfew cannot read (braces, character classes, `?`, or extglobs such as `@(a|b)` and `+(…)`) does not count, and `doctor` gives a note when it may set a shorter delay; so does a rule with a matcher depcurfew does not evaluate (`matchBaseBranches`, `matchJsonata`, `matchRepositories`, `matchCurrentAge`, and `matchFileNames` or `matchDepTypes` for the packages of a package manager), unless a package matcher already limits it to some packages. When rules give the update types different delays, `doctor` reports each one that is shorter, not only the shortest.
487
+ - **Numbers in `dependabot.yml`** are read as Dependabot's YAML library, Ruby's Psych, reads them, which follows YAML 1.1: `default-days: 010` is 8 days (octal), `1_0` and `1,0` are 10, `1:30` is 5400, and `0o12` is text. `doctor` compares the number Dependabot reads with the policy and says how it read it ("default-days: 010 in .github/dependabot.yml, which Dependabot reads as 8"), and `apply` writes the policy's number in place of one Dependabot reads otherwise. Words are read the same way: `yes`, `no`, `on` and `off` in any case are booleans and `~` and `null` in any case are null, so `apply` writes an exception such as `yes`, `off`, `1_0` or `2024-01-01` in double quotes (`- "yes"`), which Dependabot reads as the name, and treats an unquoted `- yes` in the file as the boolean it is.
414
488
  - **YAML aliases.** Dependabot does not accept YAML aliases (`*name`) in `.github/dependabot.yml` (dependabot-core loads the file without them), so `doctor` does not read a delay or an exception through one: `bot-cooldown-mismatch` gives a note at the first alias instead of a result, `apply` writes the `cooldown` of an entry out in place of an alias and warns that Dependabot reads nothing from the file (`bot-config-unreadable`), and `init` notes it, since it does not keep a longer delay from such a file in `cooldown.minimum`. GitHub Actions workflows may use aliases: `doctor` and detection read the `env` maps, jobs and steps of a workflow through them, at most 100 aliases per file.
415
489
  - **How old an action is** comes from git. Renovate takes the commit date of a tag, the tagger date of an annotated tag, or the date of a GitHub release of that version when that is newer, and holds an update without a date; Dependabot takes the date of the GitHub release, then of the tag, then of the commit, and lets an update without a date through. Whoever makes a commit or a tag sets its git date, so the delay protects actions less than packages from a registry, which records when a version was published.
416
490
 
@@ -425,14 +499,33 @@ depcurfew configures Composer (PHP packages from Packagist) from the same `depcu
425
499
  - **Lock again after `apply`.** Composer 2.11 hashes `config.policy` into `composer.lock`, so after `apply` changes it, `composer install` warns that the lock file is out of date and `composer validate` fails; `apply` says so (`lockfile-out-of-date`), only in the run that changes the setting: a later `apply` and `doctor` do not compute the hash of `composer.lock` and do not repeat it. Run `composer update --lock`, which writes the new hash without updating packages, and commit `composer.lock`. Dependabot writes `composer.lock` with Composer 2.10.3, whose hash leaves `config.policy` out, so the same happens on each of its composer pull requests once `config.policy` is there: run `composer update --lock` on the pull request's branch before merging (`doctor` says so in its note on each Dependabot `composer` entry).
426
500
  - **Install scripts are plugins.** Composer runs the scripts of the root `composer.json` only; the code of dependencies that runs during Composer commands is plugins (type `composer-plugin`, and the legacy `composer-installer`, in any case). With `installScripts.default: "deny"`, `apply` writes the plugins of `installScripts.allow` into `allow-plugins` as `true` and those of `installScripts.deny` as `false` (patterns as they are, since Composer takes them). An unlisted plugin makes Composer ask, or fail without a terminal (one that declares `extra.plugin-optional` is skipped there). `apply` replaces `"allow-plugins": true` with the list, `{}` when the policy lists no plugins (`allow-all-replaced`), and never changes an entry it did not write: an earlier key that blocks a plugin the policy allows, such as a final `"*": false`, gives `deny-conflicts-with-file` with the fix to put the plugin before it. `doctor` lists the plugins in `composer.lock` that `allow-plugins` lets run but the policy does not allow, and those it does not list. A `composer.lock` without `plugin-api-version` (or with one below 2.2.0) next to an empty or missing `allow-plugins` makes Composer 2.2 to 2.9 run every plugin without asking, so `doctor` reports each plugin the policy does not allow as an error; `composer update --lock` records `plugin-api-version`. `apply` does not add a catch-all `"*": false` for this: Composer takes the first key that matches, and the plugins `apply` adds go at the end, where such a key would hide them.
427
501
  - **No switch for dependency sources.** Composer has no setting that forbids VCS, path, artifact or package repositories (`source-not-enforceable`), but only the root `composer.json` declares them. `doctor` reports the packages of `composer.lock` that come from such a repository, and a repository of a denied kind whose packages it cannot find in the lockfile. A package of `composer.lock` without a `dist`, or with a `dist` without a type, is installed from its `source`, a VCS checkout, whatever `composer.json` declares, so it counts as a VCS package, and so does one that `config.preferred-install` of `composer.json` makes Composer check out: `"source"`, a pattern object whose first matching key gives anything but `"dist"` or `"auto"`, and `"auto"` for a dev version (`dev-main`, `1.x-dev`). Composer's default `"dist"` takes the archive, also of a dev version. Such packages are one error per entry of `config.preferred-install`, at its line in `composer.json`, naming the packages with their lines in `composer.lock`, and the fix is in `composer.json` (remove the entry or set it to `"dist"`), or `sources.git: "allow"`; `init` notes them (`composer-source-checkouts`). An archive from `api.github.com`, `gitlab.com`, `bitbucket.org` or `codeberg.org` next to a source on another host than its own (`github.com` for `api.github.com`; hosts are compared as URLs compare them, so `github.com:443` is `github.com`, and `www.github.com` is `github.com`, as Composer's GitHub driver reads it) gives a warning while the policy denies VCS sources: Packagist.org never writes such an entry, and Composer installs from that source with `--prefer-source`, with `preferred-install` set in `COMPOSER_HOME/config.json`, and before 2.10.0 when the archive cannot be downloaded (2.10 does that only with `source-fallback`, which 2.11 removed). An archive from a host that neither the archives of Packagist.org (`api.github.com`, `gitlab.com`, `bitbucket.org`, `codeberg.org`) nor a repository of `composer.json` use is a source depcurfew does not recognize: one warning per host while the policy denies a source, naming its packages with their lines. A Composer repository of `composer.json` may keep its archives on another host than its own; depcurfew knows that of WPackagist (`https://wpackagist.org` serves `downloads.wordpress.org`), and for another one, such as `packages.drupal.org` with its archives on `ftp.drupal.org`, the warning names the declared repositories and says how to check that one of them serves those archives, since `composer.lock` does not say which repository a package comes from. `doctor --osv` looks up every release of `composer.lock`, also one from such a host and one Composer checks out of its source, but not a package of a VCS repository of `composer.json`, whose code is not the release on Packagist.
428
- - **Settings that turn protections off.** The rule `composer-config-weakens` reports `"policy": false`, a `cooldown` or `block` turned off, malware blocking turned off or limited to updates, a repository with `"require-published-time": false` or `"filter": false`, `"allow-plugins": true` (or a key that matches every plugin, `"*"` or `"*/*"`) while the policy denies plugins, `"secure-http": false`, `"disable-tls": true`, and `COMPOSER_POLICY=0`, `COMPOSER_NO_BLOCKING` or `COMPOSER_NO_SECURITY_BLOCKING` set to `1`, `true`, `on`, `false` or `off` (any other value makes Composer stop with an error), a shorter `COMPOSER_POLICY_COOLDOWN_PERIOD`, `COMPOSER_POLICY_COOLDOWN_BLOCK=0` or `COMPOSER_POLICY_MALWARE_BLOCK=0` in the `env` of a CI workflow.
502
+ - **Settings that turn protections off.** The rule `composer-config-weakens` reports `"policy": false`, a `cooldown` or `block` turned off, malware blocking turned off or limited to updates, a repository with `"require-published-time": false` or `"filter": false`, `"allow-plugins": true` (or a key that matches every plugin, `"*"` or `"*/*"`) while the policy denies plugins, `"secure-http": false`, `"disable-tls": true`, and `COMPOSER_POLICY=0`, `COMPOSER_NO_BLOCKING` or `COMPOSER_NO_SECURITY_BLOCKING` set to `1`, `true`, `on`, `false` or `off` (any other value makes Composer stop with an error), a shorter `COMPOSER_POLICY_COOLDOWN_PERIOD`, `COMPOSER_POLICY_COOLDOWN_BLOCK=0` or `COMPOSER_POLICY_MALWARE_BLOCK=0` in the `env` of a CI workflow (of the workflow, a job, its container or a step), exported or set before a command in a `run` step, written to `$GITHUB_ENV` (which the later steps of the job get), or set with `ENV` or as the default of an `ARG` in a Dockerfile stage (Docker gives `RUN` the build arguments as variables, under `ENV`). It also reports `composer config policy.cooldown false` and the other settings above written by `composer config` in a `run` step or a `RUN` instruction (Composer takes `true`, `false`, `1` and `0` there and writes the setting into `composer.json` for the commands after it), and `--no-cooldown`, `--no-blocking`, `--no-security-blocking` or `--no-secure-http` on a `composer` command there.
429
503
  - **Bots.** `apply` gives Dependabot `composer` entries the cooldown and the `composer:` exceptions, and Renovate a rule for them limited to the `packagist` datasource. Dependabot counts its composer cooldown from the release time the package author writes and updates `composer.lock` with a Composer without the cooldown, so `doctor` gives each such entry a note; Renovate counts from `published-time`.
430
504
  - **Checking before an install.** `depcurfew check composer:vendor/name` (also `composer:vendor/name:^3.9`, as `composer require` takes it), the Claude Code hook (`composer require`, `composer update` of named packages, `composer global require`, `composer create-project`) and `check_packages` of `depcurfew mcp` look Composer packages up on Packagist: see [Guarding installs](#guarding-installs).
431
- - **Not covered.** `doctor` reads the Composer variables of the `env` maps of a workflow, not options and variables in a `run` step (`composer update --no-cooldown`, `--no-blocking`, `COMPOSER_POLICY=0 composer update`). `COMPOSER_HOME/config.json` and `COMPOSER_HOME/composer.json` (Composer's configuration for the whole machine and its global project) are not read or written, so a repository, a `minimum-stability` or a `preferred-install` they set is not seen by `doctor`, `check` or the hook; the hook asks about a command that sets `COMPOSER_HOME` itself, and about `composer global update` of a package named without a constraint, whose constraint is in `COMPOSER_HOME/composer.json`. Command-line options in CI steps, such as `--prefer-source`, are not read either.
505
+ - **Not covered.** `composer config --global` (or `--file`) in CI writes `COMPOSER_HOME/config.json` or another file, which `doctor` does not follow. `COMPOSER_HOME/config.json` and `COMPOSER_HOME/composer.json` (Composer's configuration for the whole machine and its global project) are not read or written, so a repository, a `minimum-stability` or a `preferred-install` they set is not seen by `doctor`, `check` or the hook; the hook asks about a command that sets `COMPOSER_HOME` itself, and about `composer global update` of a package named without a constraint, whose constraint is in `COMPOSER_HOME/composer.json`. Command-line options in CI steps, such as `--prefer-source`, are not read either.
506
+
507
+ ### Ruby projects
508
+
509
+ depcurfew configures Bundler (Ruby gems from rubygems.org) from the same `depcurfew.json`, and the bots for gems: Dependabot `bundler` entries and Renovate.
510
+
511
+ - **Detection.** A `Gemfile`, `gems.rb`, `Gemfile.lock` or `gems.locked` in the directory means Bundler; with both `gems.rb` and `Gemfile`, Bundler reads `gems.rb`, and so does depcurfew (only a regular file counts, as for Bundler: a directory named `gems.rb` does not). The version is the one under `BUNDLED WITH` at the end of the lockfile, which Bundler installs and switches to. The `bundler` input of `ruby/setup-ruby` (a version; `Gemfile.lock`, `default`, `latest` and `none` name none), `gem install bundler -v <version>` (also `-v<version>`, `--version=<version>` or `bundler:<version>`) and `bundle _<version>_ install` in a CI job or a Dockerfile count as versions too. `depcurfew explain` lists what it found.
512
+ - **The release-age delay is the `cooldown:` of the source.** Bundler 4.0.13 and later hold back gem versions younger than the `cooldown:` days of the `source` call that serves them. `apply` writes the policy's delay in whole days, rounded up (`"36h"` becomes `cooldown: 2`), into `source "https://rubygems.org"` of the Gemfile, which is committed, so every developer and CI run get it. It edits only a plain call with a literal URL on a line of its own that starts a statement at the top level of the Gemfile, outside any block, method, class, condition, loop, lambda or `begin` (with or without parentheses, with one existing `cooldown:` or `:cooldown =>`, and with a comment after it), and keeps the rest of the line and of the file as they are. The `cooldown:` is read as Ruby reads an integer (`010` is 8 days, `0x10` 16).
513
+ - **The Gemfile is read as Ruby reads it.** Bundler runs the Gemfile as Ruby code, so depcurfew reads it as Ruby's lexer does: comments, lines between `=begin` and `=end`, everything after `__END__` (or after a NUL, `^D` or `^Z` outside strings and comments), strings and `%q`, `%w`, `%(...)` and other literals over several lines with any delimiter, and the bodies of heredocs (also `<<~"END-X"`, which ends only at a line that is exactly `END-X`) are no calls. `\r\n` is one newline, for `apply` and `doctor` alike, as for Ruby. Where Ruby's reading depends on something only Ruby knows, depcurfew stops: after a name that may be a local variable or a method, `platforms %w[ruby]` is a `%w` literal or a modulo, and so with `/`, `<<`, `?` and `:` after such a name and a space; also `alias`, `undef` and syntax errors (an `end` or `)` without its opening, a `do` or `if` without its `end`). It stops as well where Ruby 3.4 and newer (Prism, the default parser since 3.4) and Ruby 3.3 and older (parse.y) read the same text differently: `?` before a character beyond ASCII (`?€or` is a character literal for one and a ternary for the other), `=begin` followed by `^D` or `^Z`, and `$0` followed by a name. Such a Gemfile is not edited at all (`cooldown-not-written` with the line and what to change there, such as `platforms(%w[ruby])`), and `doctor` does not count a cooldown in it.
514
+ - **What apply does not edit is reported.** A source with a block, a modifier (`if`, `unless`), other options, a URL depcurfew cannot read or a call over several lines gives `cooldown-not-written` with its line, and so does a Gemfile without `source "https://rubygems.org"`. So do a source inside a block, method, class, condition, loop, lambda or `begin`, which Ruby runs only when that code runs, a `cooldown:` given twice (Ruby keeps the last of two equal keys, and Bundler takes a symbol key over a string key), a `cooldown:` whose value goes on (`cooldown: 7 - 7`, or a next line that starts with `&&`, `||`, `and` or `or`, which Ruby 4.0 joins to the call as it joins `.method`), and a source whose URL depcurfew cannot read also next to a plain call; `doctor` reports the same place as an error of `config-drift`, and as a warning when the call already has a `cooldown:` at least as long as the policy, since depcurfew cannot tell whether Ruby runs it. The fix never asks for a shorter `cooldown:`. The Gemfile has no markers, so a longer `cooldown:` of a plain call written by hand is replaced (`cooldown-shortened`). Other gem servers keep their own `cooldown:`; `cooldown: 0` exempts a private one, as Bundler documents.
515
+ - **Bundler 4.0.14 for projects with a lockfile.** Bundler 4.0.13 loses the `cooldown:` of a Gemfile source once `Gemfile.lock` exists, which 4.0.14 fixed; older versions accept the keyword and ignore it without a word. `apply` writes nothing for an older pinned version (`version-too-old`, which names `bundle update --bundler`), and `doctor` reports `BUNDLED WITH` and the CI versions below what the project needs (`manager-version-too-old`).
516
+ - **What the delay covers.** Bundler applies it when it picks versions: `bundle install` without a lockfile or for gems added to the Gemfile, `bundle update`, `bundle lock --update` and `bundle add`. Versions already in `Gemfile.lock` install as they are, also younger ones, so review lockfile changes as before. A version without a publication date (a gem server without the v2 compact index) is not held back. When every version that fits is too new, Bundler stops and names `--cooldown 0`.
517
+ - **`.bundle/config` is not written.** Bundler's `cooldown` setting (`bundle config set cooldown N`, `BUNDLE_COOLDOWN`) applies to every source and takes the place of the Gemfile's, so it would also end the exemption of a private server, and `.bundle/config` is usually local and git-ignored (`bundle gem` ignores `/.bundle/`). `apply` leaves it alone. The rule `bundler-config-weakens` reports `BUNDLE_COOLDOWN` with fewer days than the policy, or `0`, in `.bundle/config` (which Bundler reads before the environment), in the `env` of a CI workflow, written to `$GITHUB_ENV` by an earlier step of the job, before a `bundle` command, in a Dockerfile `ENV` or as the default of a Dockerfile `ARG` (which `RUN` gets as a variable, under `ENV`), `bundle config set cooldown N` (with `--local` or `--global`, and the older `bundle config cooldown N`) in a `run` step or a `RUN` instruction, which writes the setting for the `bundle` commands after it, and a shorter `--cooldown` on `bundle install`, `update`, `lock`, `add` or `cache` in a CI job or a Dockerfile, also as Bundler's CLI reads them: `bundle i`, `bundle package` and `bundle pack`, an unambiguous prefix such as `bundle inst`, a bare `bundle`, which installs, and the hidden `bundle install_or_cli_help` (also `install-or-cli-help`), which runs install. `.bundle/config` is read as Bundler reads it, line by line and not as YAML: quotes stay unless they enclose the whole value (`BUNDLE_COOLDOWN: "14" # two weeks` is the text `"14"`, so 0), `#` starts a comment, the last line of a key counts, and only a key spelled exactly `BUNDLE_COOLDOWN` at the top level is the setting. Bundler reads the setting with `to_i`, so a value that does not start with a digit (an empty one, `~`, `true`, `0x10`) turns the delay off and `1e3` is 1 day; a number in a workflow `env` is read as written.
518
+ - **No exceptions for gems.** Bundler has no per-gem exception, and the policy's package lists have no prefix for Ruby gems: the cooldown exceptions of the policy stay npm, Python and Composer names. When the policy has exceptions, `apply` says that they do not apply to Bundler (`exclude-unsupported`). To take a newer version of one gem sooner, review it and run `bundle update <gem> --cooldown 0 --conservative`.
519
+ - **Install scripts are native extensions.** Bundler builds the native extensions of a gem (its `extconf.rb`, `Rakefile`, `CMakeLists.txt` or `Cargo.toml`) when it installs it, which runs code of the gem, and has no setting that stops this or allows it for some gems only. With `installScripts.default: "deny"`, `apply` and `explain` say that it cannot be enforced for Bundler (`scripts-not-enforceable`).
520
+ - **No switch for dependency sources.** A Gemfile can name git sources (`git:`, `github:`) and paths (`path:`), and Bundler has no setting that forbids them (`source-not-enforceable`); `doctor` reads only the version of `Gemfile.lock`, not its gems and sources, so review such sources by hand.
521
+ - **Bots.** `apply` gives Dependabot `bundler` entries the cooldown (their own `exclude` stays) and Renovate the delay at the top level, which covers its `bundler` manager. The Renovate rule for the policy's npm exceptions is limited to the `npm` datasource, so an npm name does not exempt a gem of the same name. `bot-cooldown-mismatch` compares the bots' delay for gems with the `cooldown:` of the Gemfile, as for the other package managers; Renovate rules by manager `bundler`, category `ruby` or datasource `rubygems` count. Renovate updates `Gemfile`, not `gems.rb`.
522
+ - **Not covered.** `check`, the Claude Code hook and `depcurfew mcp` do not look gems up on rubygems.org (`gem install`, `bundle add`). `doctor` does not check the gems of `Gemfile.lock` (`lockfile-forbidden-source`, `lockfile-install-scripts` and `known-malicious` do not apply to it) and does not read `~/.bundle/config`, `BUNDLE_APP_CONFIG` or `BUNDLE_GEMFILE`. The Bundler a `ruby` Docker image comes with is not read: Bundler switches to the version of `BUNDLED WITH`.
432
523
 
433
524
  ## doctor rules
434
525
 
435
- Findings have a level: an **error** makes `depcurfew doctor` exit with 1, a **warning** does too when `doctor.failOn` in `depcurfew.json` or `--fail-on` is `warning` (the policy of `depcurfew init` sets it; without it a warning does not), and a **note** never does. When warnings make doctor fail, the line under the counts says where that was set, with how to fail on errors only, and `--json` has `failOn` (`error` or `warning`), `failOnFrom` (`option`, `policy` or `default`) and `strict` (`true` when warnings fail). Things doctor could not check (a binary lockfile, a lockfile format without install-script data, a missing policy) are listed as notes after the findings, not as findings: each line starts with `note` and ends with its code (the rule that could not check something, or a code such as `policy-missing`, or `script-not-followed` for a CI run step or a Dockerfile `RUN` that changes the directory more than 256 times or to a path longer than 4096 characters, or sets more than 1024 variables, after which depcurfew does not follow the directory or the variables), with what to do on the next line when there is something to do, and the line of counts counts them as notes. In `--json` each entry of `notices` has `level` (`note`), `code`, `location` and `file` when it concerns one file (`location.line` too when it concerns one line, such as the step or instruction of `script-not-followed`, which SARIF uses for the region), `message`, and `fix` when there is one; `summary` counts the findings only. Findings point at the line, and for a syntax error also the column (`location.column`), where the problem is, also a problem of `depcurfew.json`, which gets the line of the field it names. Below them, doctor says what it checked: the package managers and bots it found with the version it checked against, the files it read and every check with what it checks and its result, `passed`, the problems it found, `skipped` when it needs a policy (`needs a valid depcurfew.json` when the file is there but invalid), or `not applicable` when it does not concern the project, such as the Python checks without a Python tool or the bot checks without an update bot (`checked` in `--json`, where the result is `passed`, `problems`, `skipped` or `not-applicable`).
526
+ Findings have a level: an **error** makes `depcurfew doctor` exit with 1, a **warning** does too when `doctor.failOn` in `depcurfew.json` or `--fail-on` is `warning` (the policy of `depcurfew init` sets it; without it a warning does not), and a **note** never does. When warnings make doctor fail, the line under the counts says where that was set, with how to fail on errors only, and `--json` has `failOn` (`error` or `warning`), `failOnFrom` (`option`, `policy` or `default`) and `strict` (`true` when warnings fail). Things doctor could not check (a binary lockfile, a lockfile format without install-script data, a missing policy) are listed as notes after the findings, not as findings: each line starts with `note` and ends with its code (the rule that could not check something, or a code such as `policy-missing`, or `script-not-followed` for a CI run step or a Dockerfile `RUN` that changes the directory more than 256 times or to a path longer than 4096 characters, or sets more than 1024 variables, after which depcurfew does not follow the directory or the variables, or `workspace-search-limit` when the workspace patterns of one file, such as `**`, lead through more than 20000 directories, after which depcurfew stops looking for workspace packages; a directory reached again through a symlink, such as one that points back to the root, is searched once and is one package, and the root is never a workspace package of itself), with what to do on the next line when there is something to do, and the line of counts counts them as notes. In `--json` each entry of `notices` has `level` (`note`), `code`, `location` and `file` when it concerns one file (`location.line` too when it concerns one line, such as the step or instruction of `script-not-followed`, which SARIF uses for the region), `message`, and `fix` when there is one; `summary` counts the findings only. Findings point at the line, and for a syntax error also the column (`location.column`), where the problem is, also a problem of `depcurfew.json`, which gets the line of the field it names. Below them, doctor says what it checked: the package managers and bots it found with the version it checked against, the files it read and every check with what it checks and its result, `passed`, the problems it found, `skipped` when it needs a policy (`needs a valid depcurfew.json` when the file is there but invalid), or `not applicable` when it does not concern the project, such as the Python checks without a Python tool or the bot checks without an update bot (`checked` in `--json`, where the result is `passed`, `problems`, `skipped` or `not-applicable`).
527
+
528
+ Without `depcurfew.json` (and without `--user`), the rules that need a policy (marked "needs depcurfew.json" below) compare the repository with the policy `depcurfew init` would write there: the default policy, with the longer release-age delay and the projects `init` would keep, and the packages it would put into `installScripts.deny`. Every finding of theirs is a note, whatever its usual level, so it never makes `doctor` fail, whatever `--fail-on` or `--strict` say, and `doctor.failOn` of the default policy does not apply. The message names "the default policy of depcurfew init", and the fix runs `depcurfew init` and then `depcurfew apply` (`depcurfew apply --dry-run` shows the changes first). The text shows these notes after the other findings, under "Compared with the default policy (no depcurfew.json):", and the line of counts counts them as notes. `config-drift` gives one note per file instead of one per difference: a package manager that waits less than `cooldown.minimum` now, with where its delay comes from (a setting, or the default of its version), and the settings `apply` would set or remove, by name and value as the tool reads the file (a setting of every entry of a list, such as the update entries of a bot, is named once, with `[]` after the list); a file whose settings already hold gives none, also when they were written by hand outside depcurfew's block. A note names at most 6 settings and counts the rest. Of one rule in one file at most 5 notes are shown, and one more says how many are left out. Two rules that run without a policy as well, `manager-version-too-old` and `unreadable-config`, run against it once more: a pinned package manager too old for a setting the default policy needs, and a lockfile that only the rules that need a policy read and that cannot be parsed, are notes like the others. Their findings without a policy keep their level and are not repeated, and a version problem that a setting `apply` writes for the repository would settle is left out, since `config-drift` names that setting. The list of what was checked shows these rules with their result and "(compared with the default policy)" instead of skipped. In `--json` each such finding has `"policy": "default"`, the top level has `policySource` (`"file"` for `depcurfew.json`, `"default"` for this comparison, `null` when the rules that need a policy did not run, as with an invalid `depcurfew.json`), and the entry of each such rule in `checked.rules` has `"policy": "default"`; SARIF has them as results with level `note`, the same message and `properties.policy` `"default"`. The notice `policy-missing` says which policy was used. Of the projects `init` would list, the first 20 are compared: each one is detected and checked by every rule that needs a policy, so a repository of thousands of them would take seconds. With more, the notice `projects-not-compared` after it says how many were left out and names the first five; `doctor` checks them all once `depcurfew init` has listed them in `depcurfew.json`. `known-malicious` looks up the lockfiles of the compared projects too, in the local OSV.dev data or with `--osv`, and a version listed there is an error that fails `doctor`, as one in the root lockfile is: a known malicious locked version is a fact of the lockfile, not a difference from a policy. An invalid `depcurfew.json` is reported as before (`invalid-policy`), and only the rules that need no policy run. The [GitHub Action](#github-action) does not make this comparison: without `depcurfew.json` it runs only the rules that need no policy, as before.
436
529
 
437
530
  <!-- BEGIN GENERATED: rules -->
438
531
  ### `invalid-policy`
@@ -445,7 +538,7 @@ Fix: Fix what the message lists; an editor that reads the $schema of depcurfew.j
445
538
 
446
539
  ### `invalid-project`
447
540
 
448
- **error** · needs depcurfew.json · A directory listed in projects in depcurfew.json does not exist, is not a directory, is a symlink, has a depcurfew.json of its own or overlaps a workspace package of the root.
541
+ **error** · needs depcurfew.json (without one: a note against the default policy) · A directory listed in projects in depcurfew.json does not exist, is not a directory, is a symlink, has a depcurfew.json of its own or overlaps a workspace package of the root.
449
542
 
450
543
  depcurfew checks and configures each listed directory as a project of its own. A directory that is missing or misspelled is never checked, so its package managers keep whatever configuration they have; one with a depcurfew.json of its own would follow two policies, and a workspace package of the root is configured with the root already. depcurfew apply writes nothing while one of them is listed.
451
544
 
@@ -461,9 +554,9 @@ Fix: Run the project's copy of depcurfew (install the project's dependencies fir
461
554
 
462
555
  ### `manager-version-too-old`
463
556
 
464
- **error** · A declared package manager version, or the npm or pip that comes with a declared Node.js or Python version, is older than a setting the repository relies on.
557
+ **error** · without depcurfew.json also a note against the default policy · A declared package manager version, or the npm or pip that comes with a declared Node.js or Python version, is older than a setting the repository relies on.
465
558
 
466
- Package managers ignore settings they do not know yet; npm 10 does not even warn. When packageManager, devEngines, Volta, .tool-versions, a CI step or a Dockerfile pins an older version, the setting is silently not applied there. The same holds for the npm that comes with the Node.js a CI job, a Dockerfile stage or a version file (.nvmrc, .node-version) chooses, and for the pip of a python image: Node.js 22 comes with npm 10, which has no min-release-age. Composer cannot pin its own version: a composer.lock whose plugin-api-version shows that a Composer older than 2.11 wrote it gives a warning, and otherwise a note says that the cooldown needs Composer 2.11.
559
+ Package managers ignore settings they do not know; npm 10 does not even warn. When packageManager, devEngines, Volta, .tool-versions, mise.toml, a CI step or a Dockerfile (pipx install poetry==X, the uv image) pins an older version, the setting is silently not applied there, and so for the npm of the Node.js a CI job, a Dockerfile stage or a version file chooses and the pip of a python image: Node.js 22 comes with npm 10, which has no min-release-age. npm install -g npm@<range> counts as the newest release of it depcurfew knows. Composer pins no version: a composer.lock whose plugin-api-version shows a Composer older than 2.11 gives a warning, otherwise a note says the cooldown needs 2.11. Bundler switches to the version of BUNDLED WITH in Gemfile.lock, which counts as a pin (cooldown: in a Gemfile needs 4.0.14 once Gemfile.lock exists), as do the bundler input of ruby/setup-ruby and gem install bundler -v. So is the release yarnPath in .yarnrc.yml names (yarn-<version>.cjs), which every Yarn from 1.18 runs.
467
560
 
468
561
  Fix: Raise the declared version to at least the version that introduced the setting, everywhere it is declared, or install a newer npm or pip before installing packages.
469
562
 
@@ -487,7 +580,7 @@ Fix: Install Corepack where that Node.js runs (npm install -g corepack, then cor
487
580
 
488
581
  **error** · A setting or file is ignored by the package manager version in use.
489
582
 
490
- Package managers drop old settings and files in new major versions and only accept new forms from some version on. The configuration stays in the repository and looks effective while the version in use ignores it.
583
+ Package managers drop old settings and files in new major versions and only accept new forms from some version on. The configuration stays in the repository and looks effective while the version in use ignores it. The version in use of Yarn is the release yarnPath in .yarnrc.yml names, which every Yarn from 1.18 runs instead of itself, so a packageManager pin of another release has no effect (a warning).
491
584
 
492
585
  Fix: Move the setting to the place and form this version reads.
493
586
 
@@ -507,49 +600,73 @@ With sharedWorkspaceLockfile: false, pnpm 10 installs every workspace project se
507
600
 
508
601
  Fix: Remove security settings from package .npmrc files, or keep the default shared lockfile.
509
602
 
603
+ ### `pnpm-config-unverifiable`
604
+
605
+ **warning** · pnpm loads a pnpmfile or a config-dependency plugin that can change every setting, takes a security setting from a config dependency, or stops at include in pnpm-workspace.yaml.
606
+
607
+ The updateConfig hook of a pnpmfile (.pnpmfile.mjs, .pnpmfile.cjs, or the files the pnpmfile and globalPnpmfile settings name) and of the pnpmfile pnpm loads by itself from a config dependency named pnpm-plugin-*, @*/pnpm-plugin-* or @pnpm/plugin-* can change every setting after pnpm has read pnpm-workspace.yaml, so the delay and the build settings depcurfew checks may not hold; ignoreScripts does not stop them, ignorePnpmfile does. A plugin the knowledge base knows to set a shorter release-age delay than depcurfew.json is an error. From pnpm 12.15, include in pnpm-workspace.yaml can give a setting to a config dependency, whose values depcurfew cannot read, or to a file outside the directory; and an include pnpm rejects (ERR_PNPM_INVALID_WORKSPACE_INCLUDE, ERR_PNPM_DUPLICATE_SETTING, ERR_PNPM_SETTING_NOT_DELEGATED) makes every pnpm command stop.
608
+
609
+ Fix: Read what the pnpmfile or plugin changes; set pnpmfile: [] when the project needs no pnpmfile, remove plugins it does not need, keep security settings in pnpm-workspace.yaml or a local file of include, and fix what pnpm rejects in include.
610
+
510
611
  ### `python-config-ignored`
511
612
 
512
613
  **error** · A Python tool ignores a setting where it is written.
513
614
 
514
- uv reads uv.toml instead of [tool.uv] when both exist, and in a workspace only the root; pip reads no configuration from the project and rejects options it does not know in requirements files, so a CI job or a Dockerfile stage of the python image installs without the release-age delay unless it sets PIP_UPLOADED_PRIOR_TO or passes --uploaded-prior-to; PDM in uv mode installs without only-binary; Pipenv with resolver_backend = "pure-python" locks without cool-down-period. A setting in such a place looks effective but is not, and an exclude-newer date freezes dependencies instead of delaying new versions.
615
+ uv reads uv.toml instead of [tool.uv] when both exist, and in a workspace only the root; pip reads no configuration from the project (a pip.conf counts only where PIP_CONFIG_FILE names it) and rejects options it does not know in requirements files, so a CI job or a Dockerfile stage of the python image installs without the release-age delay unless it sets PIP_UPLOADED_PRIOR_TO or passes --uploaded-prior-to; PDM in uv mode installs without only-binary; Pipenv with resolver_backend = "pure-python" locks without cool-down-period. A setting in such a place looks effective but is not, and an exclude-newer date freezes dependencies instead of delaying new versions.
515
616
 
516
617
  Fix: Move the setting to the file the tool reads.
517
618
 
619
+ ### `ci-weakens-policy`
620
+
621
+ **error** · needs depcurfew.json (without one: a note against the default policy) · A CI workflow or a Dockerfile turns off or weakens, for the commands it runs, a protection depcurfew apply wrote for npm, pnpm, Yarn, Bun, Deno, uv, Poetry or PDM.
622
+
623
+ Package managers take settings from the command line and the environment over their configuration files, so a workflow or a Dockerfile can undo what depcurfew apply wrote for every install it runs: NPM_CONFIG_MIN_RELEASE_AGE=0, npm_config_strict_allow_scripts=false, npm_config_allow_git=all, pnpm_config_minimum_release_age=0, YARN_ENABLE_SCRIPTS=true or UV_EXCLUDE_NEWER in the env of the workflow, a job, its container or a step, exported or set before a command in a run step, written to $GITHUB_ENV, or set with ENV or ARG in a Dockerfile; and npm --min-release-age=0 or --allow-git=all, pnpm --config.dangerously-allow-all-builds=true, uv lock --exclude-newer or pdm lock --exclude-newer on a command there. A shorter delay and install scripts or sources the policy denies are errors; a value with $ or ${{ }}, or a delay depcurfew cannot compare (a date), gives a warning. pip has python-config-ignored, Composer composer-config-weakens and Bundler bundler-config-weakens.
624
+
625
+ Fix: Remove the variable or the option, or give it a value that keeps what depcurfew.json requires; let a single package through with cooldown.exclude, installScripts.allow or sources in depcurfew.json and depcurfew apply instead.
626
+
518
627
  ### `composer-config-weakens`
519
628
 
520
- **error** · composer.json or the env of a CI workflow turns a Composer protection off: the cooldown, malware blocking, allow-plugins, secure-http or TLS.
629
+ **error** · composer.json, a CI workflow or a Dockerfile turns a Composer protection off: the cooldown, malware blocking, allow-plugins, secure-http or TLS.
521
630
 
522
- Composer 2.11 withholds new versions with config.policy.cooldown and runs only the plugins config.allow-plugins allows. "policy": false, "cooldown": false or "block": false under it, a repository with "require-published-time": false (the cooldown then counts from the time the package author wrote) or "filter": false (no malware list), "allow-plugins": true (or a "*" or "*/*" key) and "secure-http": false or "disable-tls": true turn such a protection off while the configuration still looks right; so do COMPOSER_POLICY=0, COMPOSER_NO_BLOCKING or COMPOSER_NO_SECURITY_BLOCKING set to 1, true, on, false or off, COMPOSER_POLICY_COOLDOWN_PERIOD with a shorter period and COMPOSER_POLICY_COOLDOWN_BLOCK=0 in the env of a CI workflow. Settings of the cooldown are reported when depcurfew.json sets one, allow-plugins when it denies dependency code; malware blocking and filter give warnings, because depcurfew.json does not manage them. COMPOSER_HOME/config.json on a machine is not read.
631
+ Composer 2.11 withholds new versions with config.policy.cooldown and runs only the plugins config.allow-plugins allows. "policy", "cooldown" or "block" under it set to false, a repository with "require-published-time": false or "filter": false, "allow-plugins": true (or a "*" or "*/*" key), "secure-http": false and "disable-tls": true turn such a protection off while the configuration looks right; so do COMPOSER_POLICY=0, COMPOSER_NO_BLOCKING or COMPOSER_NO_SECURITY_BLOCKING set to 1, true, on, false or off, a shorter COMPOSER_POLICY_COOLDOWN_PERIOD and COMPOSER_POLICY_COOLDOWN_BLOCK=0 in a CI workflow (env, run step, $GITHUB_ENV) or a Dockerfile (ENV, ARG, RUN), composer config policy.cooldown false and the like run there, and --no-cooldown or --no-blocking on a composer command there. Cooldown settings count when depcurfew.json sets a cooldown, allow-plugins when it denies dependency code; malware blocking and filter give warnings. COMPOSER_HOME/config.json is not read.
523
632
 
524
633
  Fix: Remove the setting or the variable, or limit it to the repository or the run that needs it.
525
634
 
635
+ ### `bundler-config-weakens`
636
+
637
+ **error** · needs depcurfew.json (without one: a note against the default policy) · .bundle/config, the env of a CI workflow or a bundle command in a CI job or a Dockerfile turns the cooldown of Bundler off or makes it shorter than the policy.
638
+
639
+ Bundler 4.0.13 and later hold back gem versions younger than the cooldown: of the Gemfile source, but the cooldown setting and the --cooldown option take its place for every source. BUNDLE_COOLDOWN with fewer days than depcurfew.json, or 0, in .bundle/config (read line by line as Bundler reads it, not as YAML: quotes stay unless they enclose the whole value, and # starts a comment), in a CI workflow (env, $GITHUB_ENV, before a bundle command) or a Dockerfile (ENV, ARG), bundle config set cooldown N in a run step or a RUN instruction, and a shorter --cooldown on bundle install, update, lock, add or cache (also bundle i, bundle package and bundle _VERSION_ install) turn the delay off or shorten it while the Gemfile still looks configured. Bundler reads the value with to_i: one that does not start with a digit counts as 0, and 1e3 as 1. A value with a ${{ }} expression gives a warning.
640
+
641
+ Fix: Remove the setting, the variable or the option, or give it at least the days of the policy; take a newer version of one gem by hand with bundle update GEM --cooldown 0 after reviewing it.
642
+
526
643
  ### `config-drift`
527
644
 
528
- **error** · needs depcurfew.json · A configuration file does not match what depcurfew apply writes for depcurfew.json.
645
+ **error** · needs depcurfew.json (without one: a note against the default policy) · A configuration file does not match what depcurfew apply writes for depcurfew.json.
529
646
 
530
- A generated setting was edited by hand, or depcurfew.json changed without running depcurfew apply, so the tools enforce something other than the policy. Entries in a tool's own install-script lists that apply does not change are reported as well: one that lets a package of installScripts.deny run its scripts is an error, like the differences depcurfew apply fixes, and one that lets a package the policy does not name run them, or that blocks a package the policy allows, is a warning. So is an entry of config.policy.cooldown.ignore in composer.json that exempts a package cooldown.exclude does not, which apply keeps unless it wrote the entry itself.
647
+ A generated setting was edited by hand, or depcurfew.json changed without depcurfew apply, so the tools enforce something other than the policy. Entries in a tool's own install-script lists that apply does not change are reported too: one that lets a package of installScripts.deny run its scripts is an error; one that lets a package the policy does not name run them, blocks a package the policy allows, or is not read by the version in use yet (Deno before 2.6.0) is a warning, as is an entry of config.policy.cooldown.ignore in composer.json that exempts a package cooldown.exclude does not, unless apply wrote it. What apply cannot write is an error where it says: the cooldown of Bundler in a Gemfile (a source with a block, a modifier or in other code, cooldown: twice, a URL from ENV, no rubygems.org source, a Gemfile depcurfew cannot read as Ruby does; a warning if that call waits as long as the policy), and a --no-binary or --only-binary=:none: of pip it cannot comment out.
531
648
 
532
649
  Fix: Run depcurfew apply (depcurfew apply --dry-run shows the difference), or change depcurfew.json if the edit was intended.
533
650
 
534
651
  ### `bot-cooldown-mismatch`
535
652
 
536
- **error** · An update bot waits less than the package manager, or for the actions of workflows less than the policy, before proposing a new version.
653
+ **error** · An update bot waits less than the package manager, or for the actions of workflows and the dependencies of the ecosystems updated by bots only less than the policy, before proposing a new version.
537
654
 
538
- The bot then opens pull requests with versions the package manager would not pick yet: they fail where the manager re-checks the lockfile or bring in younger versions, and security updates that skip the bot delay fail too. A Dependabot cooldown with an include list delays only the dependencies it names; a Renovate packageRules entry can shorten the delay for a registry, manager, category, update type or the files and depType of actions, each such entry is reported, and one with a matcher depcurfew cannot read or does not evaluate gets a note. For the actions of workflows the bot must wait cooldown.minimum. An unreadable bot file, a JSON5 Renovate file or a Dependabot file with a YAML alias gets a note per package manager that waits. With projects, a bot file inside a project gets a note (bots read only the root's), and a project the root's Dependabot configuration leaves out while it updates others gets a warning. A Dependabot composer entry gets a note: it counts from the release time the author sets.
655
+ The bot then opens pull requests with versions the package manager would not pick yet: they fail where the manager re-checks the lockfile or bring in younger versions, and security updates that skip the bot delay fail too. A Dependabot cooldown with an include list delays only the dependencies it names; a Renovate packageRules entry can shorten the delay for a registry, manager, category, update type or the files and depType of actions, each is reported, and one with a matcher depcurfew cannot read gets a note. The actions of workflows and the dependencies of Cargo, Go, Maven, Gradle and NuGet must wait cooldown.minimum; without a bot, the latter get a note. An unreadable bot file (JSON5, a YAML alias) gets a note per package manager that waits. With projects, a bot file inside a project gets a note (bots read only the root's), and a project the root's Dependabot configuration leaves out gets a warning. A Dependabot composer entry gets a note: it counts from the release time the author sets.
539
656
 
540
- Fix: Give the bot a delay at least as long as the package manager's, and for the actions of workflows as long as cooldown.minimum (depcurfew apply does this).
657
+ Fix: Give the bot a delay at least as long as the package manager's, and for the actions of workflows and the ecosystems updated by bots only as long as cooldown.minimum (depcurfew apply does this).
541
658
 
542
659
  ### `broad-exclusion`
543
660
 
544
- **error** · An exception list matches every package, or a per-scope setting shortens the delay, which turns the protection off.
661
+ **error** · An exception list matches every package, or a per-scope setting or a Renovate rule exempts packages cooldown.exclude does not, which turns the protection off.
545
662
 
546
- A release-age exception such as "*" (or a policy allow-list entry such as "@*/*") looks like a narrow exception but exempts every package, so the delay or the install-script block never applies. The same goes for a Renovate packageRules entry that turns minimumReleaseAge off for a whole datasource, Renovate manager (matchManagers) or category (matchCategories) in use, for major, minor or patch updates of every package, or with only negative patterns, which match every package they do not name, and, when depcurfew.json sets a cooldown for Renovate and the repository has workflows, for the github-actions manager, every workflow file (matchFileNames) or the depType action (matchDepTypes). In Yarn 4.17 and later, npmScopes.<scope>.npmMinimalAgeGate replaces the delay for one scope, so a shorter value there exempts that scope.
663
+ A release-age exception such as "*" (or a policy allow-list entry such as "@*/*") looks narrow but exempts every package, so the delay or the install-script block never applies. The same goes for a Renovate packageRules entry that turns minimumReleaseAge off for a whole datasource, manager (matchManagers) or category (matchCategories) in use, for major, minor or patch updates of every package, or with only negative patterns; with a cooldown for Renovate, also for the actions of the workflows (the github-actions manager, every workflow file, the depType action) and for Cargo, Go modules, Maven, Gradle or NuGet in the repository. A Renovate rule that gives npm, Python or Composer packages of some names a shorter delay than cooldown.minimum is an exception cooldown.exclude must make too, and a rule depcurfew apply wrote for the names of one registry must be limited to its datasource. In Yarn 4.17 and later, a shorter npmScopes.<scope>.npmMinimalAgeGate exempts that scope.
547
664
 
548
665
  Fix: Replace the pattern with the packages or scopes that really need the exception.
549
666
 
550
667
  ### `lockfile-forbidden-source`
551
668
 
552
- **error** · needs depcurfew.json · The lockfile installs a package from a source depcurfew.json denies.
669
+ **error** · needs depcurfew.json (without one: a note against the default policy) · The lockfile installs a package from a source depcurfew.json denies.
553
670
 
554
671
  A git repository, a tarball URL or a local path bypasses the registry and its checks (release age, provenance, integrity from the registry). The lockfile shows what installs really fetch, whatever the package manager settings say. For Composer, whose lockfile does not name the repository of a package, packages of a path or artifact repository are told by their dist, those of a VCS or package repository by the repositories of composer.json; a package without a usable dist, or one that config.preferred-install makes Composer check out, is a VCS checkout. Warnings: archives from a host that neither the archives of Packagist.org (GitHub, GitLab, Bitbucket, Codeberg) nor a repository of composer.json use (with the archive host depcurfew knows for one, such as downloads.wordpress.org for WPackagist), one per host, such an archive next to a source on another host, and a repository of a denied kind that no package of composer.lock comes from.
555
672
 
@@ -557,7 +674,7 @@ Fix: Replace the dependency with a registry version, or allow that source in dep
557
674
 
558
675
  ### `lockfile-install-scripts`
559
676
 
560
- **error** · needs depcurfew.json · The lockfile has packages with install scripts, or Python packages built from source, that depcurfew.json does not allow.
677
+ **error** · needs depcurfew.json (without one: a note against the default policy) · The lockfile has packages with install scripts, or Python packages built from source, that depcurfew.json does not allow.
561
678
 
562
679
  Install scripts run arbitrary code during installation, and so do building a Python package from source (a source distribution, a git repository or a local directory) and loading a Composer plugin. Where the package manager runs them by default, or its own allow-list lets them run, they run code the policy does not allow; where it blocks them, a package may not work. A package in installScripts.deny works without its scripts, so it is reported only while the package manager in use still runs them (or builds it from source), or when it cannot be installed (a Python package without wheels, esbuild without optional dependencies). npm, Bun (1.4.4+) and Deno (lockfile v5) record install scripts in the lockfile and composer.lock records plugins; uv.lock, poetry.lock, pdm.lock and pylock.toml show which Python packages have no wheel. pip requirements files and Pipfile.lock list hashes only, so the check does not apply to their packages from an index, only to requirements from a repository, a URL or a local path.
563
680
 
@@ -573,15 +690,15 @@ Fix: Lock again (uv lock or pdm lock) and commit the lockfile.
573
690
 
574
691
  ### `known-malicious`
575
692
 
576
- **error** · With --osv: a lockfile locks a package version that OSV.dev lists as malicious.
693
+ **error** · With --osv or the local OSV.dev data of depcurfew osv update: a lockfile locks a package version that OSV.dev lists as malicious.
577
694
 
578
- With --osv, doctor sends the name and version of every package the lockfiles it reads lock (npm, pnpm, Yarn, Bun and Deno lockfiles, uv.lock, poetry.lock, pdm.lock, Pipfile.lock, pylock.toml, requirements pinned with == and the releases in composer.lock) to api.osv.dev in batch queries, and reports each version that has a record of the OpenSSF malicious-packages database there (an id starting with MAL-). Such a version was published to steal or damage, and installing from the lockfile brings it back. It looks up what the tool installs: for npm the registry tarball of resolved, for pnpm the one of resolution.tarball (each path read as the registry reads it), for Yarn the version of resolution; an entry whose name or version says otherwise gets a warning, as does a registry entry without an exact version. Only reported malware is found, and the database admits false positives. Without --osv nothing is sent and the rule does not run; when OSV.dev cannot be reached it gives a warning instead of passing.
695
+ Doctor looks up the exact version of every package the lockfiles it reads lock (npm, pnpm, Yarn, Bun and Deno lockfiles, uv.lock, poetry.lock, pdm.lock, Pipfile.lock, pylock.toml, requirements pinned with == and the releases in composer.lock) and reports each version that has a record of the OpenSSF malicious-packages database (an id starting with MAL-): with --osv in api.osv.dev (which then gets the names and versions), else offline in the local OSV.dev data of depcurfew osv update. It looks up what the tool installs (for npm and Yarn Classic the tarball of resolved, for pnpm that of resolution.tarball, for Yarn the resolution); an entry that says otherwise gets a warning, as does a registry entry without an exact version. Local data older than 7 days gives a note; older than 30 days, or in a directory others can write, a warning; OSV.dev out of reach gives a warning. Only reported malware is found, and the database admits false positives. With neither --osv nor local data, a note says how to get them.
579
696
 
580
697
  Fix: Remove or replace the package version and lock again; if it was installed anywhere, read the record on OSV.dev and treat that machine as exposed.
581
698
 
582
699
  ### `unreadable-config`
583
700
 
584
- **error** · A configuration file or lockfile cannot be read, parsed or edited, so depcurfew cannot check it.
701
+ **error** · without depcurfew.json also a note against the default policy · A configuration file or lockfile cannot be read, parsed or edited, so depcurfew cannot check it.
585
702
 
586
703
  A package manager or bot may still read the file and its settings may weaken the policy; depcurfew fails closed and reports an error instead of passing silently.
587
704
 
@@ -590,7 +707,7 @@ Fix: Fix what the message describes: invalid syntax, missing read permission, a
590
707
 
591
708
  ## GitHub Action
592
709
 
593
- The action runs `depcurfew doctor`, prints the report to the job log, shows each finding as an annotation on its file and in a table in the job summary, each with how to fix it, uploads the SARIF report to code scanning and fails the job when doctor finds an error, or also a warning when `doctor.failOn` in `depcurfew.json` is `warning` (the input `fail-on` is empty by default and follows it) or the input `fail-on` is `warning` (as `depcurfew doctor --fail-on warning`); `fail-on: error` fails on errors only, whatever the policy says, and the depcurfew doctor check fails or passes the same way. The log and the job summary also say what was checked: the tools found, the files read and every check with its result (passed, the problems it found, skipped because it needs `depcurfew.json`, or not applicable to the project). A clean run adds a notice with the same list ("No problems found in 7 files for npm 11.21.1 (package.json). 14 checks: 9 passed, 5 not applicable."), so it is not mistaken for one that did nothing. Code scanning lists only alerts, so its entry stays empty when there is nothing to report; next to it, in the Checks tab of a pull request, the action adds the check "depcurfew doctor" with the result, the table of checks and the findings as annotations on their lines (at most 100, errors first; a finding about a whole file is shown on its first line). It needs `checks: write`, which the example below grants; without it the step only says so, as a warning in the log. The lines the action writes about its own run start with `depcurfew:` and their level (`error`, `warning` or `note`); its notes and warnings are also annotations of the same level (unless `annotations` is `false`), and its errors are the error annotation of the failing step. A workflow that runs on both `push` and `pull_request` checks the same commit twice: the second run updates the entry of the first one instead of adding another, unless both run at the same moment, since GitHub has no way to look for an entry and add one in a single step. Annotations cannot be removed from an entry, so when the first entry has annotations other than the second run's findings, the second run adds a new entry and marks the first one as replaced (neutral, with a title that says so), rather than leave findings that no longer hold under the new result. It runs from its own checkout, with nothing to install, and sets up Node.js 22 only when the runner has an older version.
710
+ The action runs `depcurfew doctor`, prints the report to the job log, shows each finding as an annotation on its file and in a table in the job summary, each with how to fix it, uploads the SARIF report to code scanning and fails the job when doctor finds an error, or also a warning when `doctor.failOn` in `depcurfew.json` is `warning` (the input `fail-on` is empty by default and follows it) or the input `fail-on` is `warning` (as `depcurfew doctor --fail-on warning`); `fail-on: error` fails on errors only, whatever the policy says, and the depcurfew doctor check fails or passes the same way. The log and the job summary also say what was checked: the tools found, the files read and every check with its result (passed, the problems it found, skipped because it needs `depcurfew.json`, or not applicable to the project). Without `depcurfew.json` the action runs only the rules that need no policy; `npx depcurfew doctor` on the command line also shows what the default policy would change. A clean run adds a notice with the same list ("No problems found in 7 files for npm 11.21.1 (package.json). 14 checks: 9 passed, 5 not applicable."), so it is not mistaken for one that did nothing. Code scanning lists only alerts, so its entry stays empty when there is nothing to report; next to it, in the Checks tab of a pull request, the action adds the check "depcurfew doctor" with the result, the table of checks and the findings as annotations on their lines (at most 100, errors first; a finding about a whole file is shown on its first line). It needs `checks: write`, which the example below grants; without it the step only says so, as a warning in the log. The lines the action writes about its own run start with `depcurfew:` and their level (`error`, `warning` or `note`); its notes and warnings are also annotations of the same level (unless `annotations` is `false`), and its errors are the error annotation of the failing step. The check goes on the commit the run checked: the head of the pull request for `pull_request` (where `GITHUB_SHA` is a merge commit), else `GITHUB_SHA`, so under `pull_request_target`, which checks out the base branch, it never marks the head of the pull request as checked. A workflow that runs on both `push` and `pull_request` checks the same commit twice: the second run updates the entry of the first one instead of adding another, unless both run at the same moment, since GitHub has no way to look for an entry and add one in a single step. Annotations cannot be removed from an entry, so when the first entry has annotations other than the second run's findings, the second run adds a new entry and marks the first one as replaced (neutral, with a title that says so), rather than leave findings that no longer hold under the new result. It runs from its own checkout, with nothing to install, and sets up Node.js 22 only when the runner has an older version.
594
711
 
595
712
  Pin it to a full commit SHA, like every action in your workflows; the example pins the commit of a release, and the comment names its tag. Pin a release at least as new as the depcurfew your project uses elsewhere (in `package.json`, the hook or `npx`): an older Action reports policy fields it does not know as an invalid policy, such as `doctor` and `projects`, which are new in 1.3.0 and which `depcurfew init` 1.3.0 writes, and names it does not know, such as `composer:` names (new in 1.4.0); it does not check Composer projects before 1.4.0 and ignores inputs it does not have, such as `osv` (new in 1.4.0). The example pins the release that was current when this README was written; when you install a newer depcurfew, pin the commit of its tag (`git ls-remote https://github.com/DevXord/depcurfew refs/tags/v<version>`). If the repository cannot use code scanning, set `upload-sarif: false`; the findings stay in the job log. `osv: true` adds the rule `known-malicious`, as `depcurfew doctor --osv` does: the runner then sends the locked package versions to `api.osv.dev` (see [Known malicious versions](#known-malicious-versions-osv)).
596
713
 
@@ -617,7 +734,7 @@ jobs:
617
734
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
618
735
  with:
619
736
  persist-credentials: false
620
- - uses: DevXord/depcurfew@20fcc21389f308180a2f9a3a404a486f08705c15 # v1.4.0
737
+ - uses: DevXord/depcurfew@2c45b13c38b5fbae4b3b7ce71d57e7cee084610e # v1.5.0
621
738
  ```
622
739
  <!-- END GENERATED: action-workflow -->
623
740
 
@@ -662,7 +779,16 @@ depcurfew:
662
779
 
663
780
  ## What depcurfew does not do
664
781
 
665
- depcurfew makes the package managers' own protections work and checks a package's age, name, install scripts and source before it is installed. It has no malware database of its own and does not scan package contents, so by default a malicious version older than the cooldown passes. With `--osv` (`check`, the Claude Code hook, `depcurfew mcp`, `doctor` and the input `osv` of the Action) it asks OSV.dev about the versions it checks and blocks those with a record of the OpenSSF malicious-packages database, but that catches only malware someone has found and reported, and the database admits false positives: see [Known malicious versions](#known-malicious-versions-osv). Keep `npm audit` or an equivalent for known vulnerabilities, which `--osv` does not report. depcurfew also does not run or sandbox anything: the protection comes from the configuration the package managers read.
782
+ depcurfew makes the package managers' own protections work and checks a package's age, name, install scripts and source before it is installed. It does not scan package contents, and it has no malware database until you download one: after `depcurfew osv update`, `check`, the Claude Code hook, `depcurfew mcp` and `doctor` block the versions that a local copy of the OpenSSF malicious-packages data of OSV.dev lists, offline; with `--osv` (also the input `osv` of the Action) they ask OSV.dev itself. Without either, a malicious version older than the cooldown passes. Both catch only malware someone has found and reported, and the database admits false positives: see [Known malicious versions](#known-malicious-versions-osv). Keep `npm audit` or an equivalent for known vulnerabilities, which neither reports. depcurfew also does not run or sandbox anything: the protection comes from the configuration the package managers read.
783
+
784
+ ## Alongside other supply-chain tools
785
+
786
+ depcurfew configures and checks the protections that package managers and update bots already have, so it works without a service, an account or a proxy, and sends nothing unless asked. Tools that inspect the packages themselves cover what it cannot: they look at the code of new versions and block malware before anyone has reported it to OSV.dev. The two kinds work together, each on a different layer:
787
+
788
+ - **depcurfew** holds new versions back until `cooldown.minimum` has passed, blocks install scripts, git and tarball dependencies the policy denies, look-alike names and versions with an OSV.dev malicious-packages record, and checks that the configuration really does this in every package manager, update bot, CI job and Dockerfile of the repository.
789
+ - **A malware-blocking proxy or wrapper**, such as [Aikido Safe Chain](https://github.com/AikidoSec/safe-chain), [Socket Firewall](https://github.com/SocketDev/firewall-release) or [StepSecurity Secure Registry](https://www.stepsecurity.io/secure-registry), checks each package an install downloads against its vendor's malware analysis, also for versions older than the release-age delay and, depending on the product, for ecosystems depcurfew does not configure. Read each product's terms for its license, the data it sends and the ecosystems it covers.
790
+
791
+ Running both: apply depcurfew as described above, and install the other tool as its documentation says. depcurfew's configuration stays in the repository's own files, so a wrapper that calls the package manager, or a registry proxy that the package manager reaches through its registry setting, finds the release-age delay and script blocking already in place. When the proxy is a registry of its own, the project's registry setting names it; `check` and the hook then ask about installs from it, because they did not check that registry, until you pass it to them with `--registry` or `--index` (see [Guarding installs](#guarding-installs)).
666
792
 
667
793
  ## Guarding installs
668
794
 
@@ -682,47 +808,72 @@ For each package it looks up the npm registry (the one `--registry` or `registry
682
808
  - 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);
683
809
  - a deprecated version, or an abandoned Composer package (a warning);
684
810
  - a registry or index reached over plain HTTP (a warning), unless it is on this computer (`localhost`, `127.0.0.0/8` or `::1`), such as a local registry for testing;
685
- - with `--osv`, a version OSV.dev lists as malicious (`known-malicious`, an error), as described below.
811
+ - a version OSV.dev lists as malicious (`known-malicious`, an error), with the local data of `depcurfew osv update` or with `--osv`, as described below.
686
812
 
687
- 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 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`.
813
+ 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`.
688
814
 
689
- `.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`, or `@scope:registry=<URL>` in the `.npmrc` next to `depcurfew.json`. 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.
815
+ `.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.
816
+
817
+ 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.
690
818
 
691
819
  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.
692
820
 
693
821
  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.
694
822
 
695
- Git repositories, tarball URLs and local paths are checked against the `sources` section only. 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`), `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` and `config-not-read`; without `--json` each is a `note:` 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.
823
+ Git repositories, tarball URLs and local paths are checked against the `sources` section only. 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)).
696
824
 
697
825
  ### Known malicious versions (OSV)
698
826
 
699
- `--osv` asks [OSV.dev](https://osv.dev), the open database of vulnerabilities and malicious packages of the Google project `google/osv.dev`, about the versions depcurfew checks. It is off unless you pass it, on each command: without it nothing is sent to OSV.dev.
827
+ depcurfew knows malicious versions from [OSV.dev](https://osv.dev), the open database of vulnerabilities and malicious packages of the Google project `google/osv.dev`, in two ways:
828
+
829
+ - **A local copy, offline.** `depcurfew osv update` downloads 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 on `check`, the Claude Code hook, `check_packages` of `depcurfew mcp` and `doctor` look every version they check up in it, without `--osv` and without network access. Run it again to update the copy, for example once a day.
830
+ - **`--osv`, live.** `--osv` asks the API of OSV.dev about the versions depcurfew checks, which sends their names: it is off unless you pass it, on each command.
700
831
 
701
832
  ```bash
833
+ npx depcurfew osv update
834
+ npx depcurfew check zod@4 pypi:requests
702
835
  npx depcurfew check --osv zod@4 pypi:requests
703
836
  npx depcurfew doctor --osv
704
837
  ```
705
838
 
839
+ `depcurfew osv update [--ecosystem npm|pypi|packagist ...] [--accept-fewer] [--json]`:
840
+
841
+ - downloads `https://storage.googleapis.com/osv-vulnerabilities/<ecosystem>/all.zip` for `npm`, `PyPI` and `Packagist` (210 MiB, 36 MiB and 11 MiB in November 2026; `--ecosystem` picks some), keeps the records whose id starts with `MAL-` 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.jsonl` and `packagist.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 (its `Last-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;
842
+ - 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 as `tooLarge` (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, `mcp` and `doctor` do 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`);
843
+ - 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-shrunk` names both counts, and `--accept-fewer` replaces 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;
844
+ - writes to `$XDG_CACHE_HOME/depcurfew/osv` when `XDG_CACHE_HOME` is an absolute path, else `~/.cache/depcurfew/osv` on Linux, `~/Library/Caches/depcurfew/osv` on macOS and `%LOCALAPPDATA%\depcurfew\osv` on Windows: the user cache directory as Python's `platformdirs` places it. On Linux and macOS, when that directory, `depcurfew` in it or `depcurfew/osv` can be written by other users (by every user, as `/tmp` can, by a group other than the user's own primary group, or because it belongs to another user than the user and root), `osv update` gives the warning `osv-cache-shared` and still writes the data, and the lookups say the same: other users could change the data so that a malicious version passes. The fix is `chmod go-w` on a directory of your own, or an `XDG_CACHE_HOME` that only you can write, and `depcurfew osv update` again. The directories `osv update` creates are not writable by others, whatever the umask;
845
+ - reads the zip file itself, without a zip library: it inflates only the `MAL-*.json` entries 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;
846
+ - downloads to a temporary file next to the data (`.<ecosystem>.<process id>.<12 hexadecimal digits>.zip.part`, and `.jsonl.part` for 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 warning `osv-cache-shared` does not change the exit code. With `--json` it prints `schemaVersion`, `directory`, `ecosystems` (each with `file`, `url`, `downloadedAt`, `exportedAt`, `records`, `withdrawn`, `skipped`, `tooLarge` and `bytes`), `errors` (each with `code`, `level`, `location`, `ecosystem`, `message` and `fix`) and `warnings` (each with `code`, `level`, `location`, `message` and `fix`).
847
+
848
+ The local data and `--osv` report the same way:
849
+
850
+ - A version matches a record as the OSV schema evaluates `affected[]`: listed in `versions`, or within a `SEMVER` or `ECOSYSTEM` range of `introduced`, `fixed`, `last_affected` and `limit` events, compared as semantic versions for npm, as PEP 440 versions for Python and with PHP's `version_compare` for Composer, where `v8.1.9` and `8.1.9` are the same version, as in OSV.dev. Withdrawn records are never used.
851
+ - In the JSON of `check`, `check_packages` and `doctor` (and the properties of a SARIF result), `source` says where a `known-malicious` finding comes from: `osv-local` or `osv-api`.
852
+ - Local data older than 7 days gives a note (`osv-data-old` in `check` and `check_packages`, a line of the context of the hook for the agent, `known-malicious` per lockfile in `doctor`), older than 30 days a warning, each with the fix `depcurfew 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-shared` in `check` and `check_packages`, a line of the context of the hook, a `known-malicious` warning per lockfile in `doctor`), and the versions are still looked up in it: without it nothing would be found at all. In `doctor`, a lockfile of an ecosystem without local data gets a note, and without any local data and without `--osv` one note names `depcurfew osv update`; otherwise nothing changes without the data.
853
+ - With `--osv` the 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 stays `unverified`, since `--osv` asked for the live answer.
854
+
855
+ 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.
856
+
706
857
  - `check`, the Claude Code hook (`check --hook claude-code --osv`) and `check_packages` of `depcurfew mcp --osv` look 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 with `MAL-`) is an error with the code `known-malicious`: it names the record and links its page, such as `https://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 the `args` of the hook command in `.claude/settings.json` (see below); for `depcurfew mcp`, after `"mcp"`.
707
858
  - 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`: `check` exits 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.
708
- - `doctor --osv` (and the Action with `osv: true`) sends every package version the lockfiles it reads pin: `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock` (Berry), `bun.lock`, `deno.lock`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `pylock.toml`, requirements files pinned with `==` and the releases in `composer.lock` (as Composer wrote them, `v7.3.4` or `3.9.0`), in the root and in every directory of `projects`. 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: for `package-lock.json` the registry tarball of `resolved`, whose name and version npm reads from its path (`/cutekitten-test/-/cutekitten-test-1.0.1.tgz`), for `pnpm-lock.yaml` the registry tarball of `resolution.tarball` when there is one (a relative one too, which pnpm reads against the registry), and for `yarn.lock` the version of `resolution`. 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 rule `known-malicious` reports 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 `--osv` the rule does not run and shows as not applicable.
859
+ - `doctor --osv` (and the Action with `osv: 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 in `composer.lock` (as Composer wrote them, `v7.3.4` or `3.9.0`), in the root and in every directory of `projects`, and without `depcurfew.json` in the projects `depcurfew init` would list that `doctor` compares 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: for `package-lock.json` the registry tarball of `resolved`, whose name and version npm reads from its path (`/cutekitten-test/-/cutekitten-test-1.0.1.tgz`), for `pnpm-lock.yaml` the registry tarball of `resolution.tarball` when there is one (a relative one too, which pnpm reads against the registry), for the `yarn.lock` of Yarn (Berry) the version of `resolution`, and for that of Yarn Classic the registry tarball of `resolved`, read as for `package-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 rule `known-malicious` reports 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 `--osv` the 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.
709
860
  - 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.
710
861
 
711
- **Privacy.** `--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 it off where package names must not leave the network.
862
+ **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.
712
863
 
713
- **What it catches.** Only malware that has been found and reported to the OpenSSF malicious-packages database, which OSV.dev serves; a new malicious release is not there until someone reports it, which is what the release-age delay of `cooldown.minimum` gives time for. The database says it may contain false positives: if you think a record is wrong, read it on its page and report it there.
864
+ **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.
714
865
 
715
866
  ### Claude Code
716
867
 
717
868
  `depcurfew check --hook claude-code` runs as a Claude Code hook before every command of the tools that run shell commands: Bash, PowerShell and Monitor. It finds the packages the command would install or download to run, checks them and answers:
718
869
 
719
- - **deny** when a package breaks the policy, or with `--osv` is a known malicious version (Claude sees the reason and can pick another package or version);
870
+ - **deny** when a package breaks the policy, or is a known malicious version (with the local OSV.dev data or `--osv`), also where it would otherwise ask, for an exact version the local data lists (see [Known malicious versions](#known-malicious-versions-osv)) (Claude sees the reason and can pick another package or version);
720
871
  - **ask** when depcurfew cannot vouch for the command: a look-alike name, a registry it cannot reach (with `--osv` also OSV.dev), an error of its own, or a command it cannot read, such as one with `$(...)` or variables in its arguments, or a shell that runs commands from a pipe (`curl … | sh`); also when an option or variable weakens the policy for that one command, such as a release-age delay shorter than the one in `depcurfew.json` (`--min-release-age=0`, `YARN_NPM_MINIMAL_AGE_GATE=0`, `--no-time-gate`) or install scripts turned on where it restricts them (`--ignore-scripts=false`, `npm_config_ignore_scripts=false`, `bun add --trust`), even in `npm ci`, and when the same command changes the package manager's configuration or `package.json` next to an install or another command of the package manager (`npm config set registry … && npm ci`, also spelled `npm conf` or `npm c`, `npm pkg set overrides.… && npm install`, `poetry source add … && poetry add x`, `pdm config pypi.url … && pdm add x`);
721
872
  - nothing when everything checks out, so the command goes through Claude Code's usual permission prompts.
722
873
 
723
874
  Warnings, such as a deprecated version or a git source depcurfew does not look up, never decide: the command goes through the usual permission prompts, and the warnings reach Claude as context next to the result of the command, with the deprecation message in quotes as the publisher's text, which is data, not instructions.
724
875
 
725
- It follows the command lines inside a command: `$(...)` and backticks, `sh -c`, PowerShell's `-Command`, `cmd /c`, `eval`, `npx -c`, `yarn workspace <name>`, `python -m pip`, the command `uv run` runs, a package manager run by `npx`, `uvx` or `pipx run`, wrappers such as `sudo` (also `sudo -s` and `sudo -i`), `su -c`, `env` (also `env -S` in grouped options, `env -iS'…'`), `timeout`, `flock` and `xargs`, the action of a `trap`, aliases defined in the command, `.` and `source` of a package manager, and an interpreter that runs a package manager's script (`node /usr/bin/npm`, `node .yarn/releases/yarn-4.1.0.cjs`, `python3 -m pip.__main__`, `python3 pip.pyz`); a script the shell expands before an install word (`node $(which npm) i x`) makes it ask. `$'…'` is decoded as bash decodes it (`$'np\x6d' i x` is `npm i x`) and `$"…"` is read like `"…"`; a command built at run time inside another command line (`eval 'x$A'`, `sh -c '${A}x'`), brace expansion as the command (`{npm,install,x}`) and `$'…'` it cannot decode make it ask. An alias is expanded at most once while its text is read, as in bash, and a command whose aliases and nested command lines add up to more than depcurfew reads makes it ask. CRLF line endings are read as bash reads them, so a here-document ends where bash ends it. It reads options the way each tool does, including npm's and pnpm's shorthands and abbreviations (`--reg` and `--regis` for `--registry`, `--no-ign` for `--no-ignore-scripts`), abbreviated long options of pip and pipx (`--extra` for `--extra-index-url`) and short options with their value in the same word (`-ihttps://…`), so an option, or a variable such as `PIP_INDEX_URL` or `UV_INDEX`, that points the tool at another index makes it ask. Such a variable, and one that weakens the policy or changes the version or the directory, counts wherever the line sets it, as if it were set to the value that weakens the policy most: `NAME=value` is read with its value, and a value depcurfew cannot read counts as the worst one when a builtin sets the name (`printf -v NAME`, `read NAME`, `mapfile` and `readarray`, `getopts … NAME`, `wait -p NAME`, a nameref `declare -n ref=NAME`, zsh's and ksh's `print -v`, `vared` and `integer`), with `NAME+=…`, `NAME[0]=…`, `for NAME in …`, `select`, `${NAME:=…}`, an assignment in arithmetic (`let`, `(( NAME = … ))`, `NAME++`, `[[ … -eq … ]]`, subscripts, `declare -i`), inside `eval`, an alias, a `trap`, `sh -c` or `$(…)`, in the value of another variable (`N=NAME`), which the shell may use as a name, in the code of `python -c`, `node -e` or `php -r`, which depcurfew does not read, and with cmd's `set /p` and `set /a`; so does a builtin that may set a variable whose name the shell builds (`export "$(cat .env)"`). A word that only reads or mentions the name sets nothing: `printenv NAME`, `echo`, `grep NAME`, a commit message or `Composer` in a sentence, and `export NAME`, `readonly NAME` and `unset NAME`, which do not change a value. Names count in the case the tool reads them: in Bash on Linux and macOS `COMPOSER` but not `Composer`, `npm_config_*` and `YARN_*` in any case, `pnpm_config_*` all in lower or all in upper case, `PIP_` followed by the rest in any case; in PowerShell, cmd and Git Bash on Windows every case counts. The hook then asks only where that value would make it ask: a variable of the registry or of the version next to an install that names packages (`read npm_config_registry; npm ci` goes through like `npm_config_registry=… npm ci`, which installs the lockfile), one that weakens the release-age delay or the install scripts when the policy sets them, and one that turns off a protection of Composer next to any command of a package manager. It checks the version the command would install: the dist-tag `--tag` or `npm_config_tag` makes npm prefer, pre-releases that `--pre`, `--prerelease allow` and the like allow, and builds from source that the command's options or variables force (wheels refused), which count as install scripts. A script piped into an interpreter (`curl … | python3`, `irm … | iex`) or read from `<(curl …)` makes it ask, and so does a command with more than 50 packages, of which the first 50 are checked. `deno x` and `dx` are read like `npx`. Packages from requirements files (`pip install -r`) and the project's own files (`uv sync`, `poetry install`) are not checked, as with `npm install` without names; `depcurfew doctor` checks the lockfiles. When it cannot read the hook event at all (not JSON, not an object, or a `PreToolUse` event of Bash, PowerShell or Monitor without a readable `tool_input.command`, apart from a Monitor WebSocket watch), it exits with code 2, which blocks the command in every Claude Code version, and says on stderr what it got (with the code `hook-event-unreadable`) and how Claude Code runs the hook.
876
+ It follows the command lines inside a command: `$(...)` and backticks, `sh -c`, PowerShell's `-Command`, `cmd /c`, `eval`, `npx -c`, `yarn workspace <name>`, `python -m pip`, the command `uv run` runs, a package manager run by `npx`, `uvx` or `pipx run`, wrappers such as `sudo` (also `sudo -s` and `sudo -i`), `su -c`, `env` (also `env -S` in grouped options, `env -iS'…'`), `timeout`, `flock` and `xargs`, the action of a `trap`, aliases defined in the command (also as `BASH_ALIASES[name]=…`), command names that `hash -p`, `BASH_CMDS[name]=…` or zsh's `hash name=…` give a program, `.` and `source` of a package manager, and an interpreter that runs a package manager's script (`node /usr/bin/npm`, `node .yarn/releases/yarn-4.1.0.cjs`, `python3 -m pip.__main__`, `python3 pip.pyz`); a script the shell expands before an install word (`node $(which npm) i x`) makes it ask, and so does a command that may give a command name a program in a way it does not read: `hash` with a word the shell expands, or `BASH_CMDS` and `BASH_ALIASES` set otherwise than one element with a written-out value (`BASH_CMDS=(…)`, `+=`, `declare`, `printf -v`, `read`, a nameref, `${BASH_CMDS[x]:=…}`). `$'…'` is decoded as bash decodes it (`$'np\x6d' i x` is `npm i x`) and `$"…"` is read like `"…"`; a command built at run time inside another command line (`eval 'x$A'`, `sh -c '${A}x'`), brace expansion as the command (`{npm,install,x}`) and `$'…'` it cannot decode make it ask. An alias is expanded at most once while its text is read, as in bash, and a command whose aliases and nested command lines add up to more than depcurfew reads makes it ask. CRLF line endings and backslash-newline continuations are read as bash reads them, so a here-document ends where bash ends it: with an unquoted delimiter bash joins a line that ends in a backslash to the next one before it strips the tabs of `<<-` and compares the line with the delimiter, and the delimiter word loses a backslash-newline and its quotes as bash removes them. It reads options the way each tool does, including npm's and pnpm's shorthands and abbreviations (`--reg` and `--regis` for `--registry`, `--no-ign` for `--no-ignore-scripts`), abbreviated long options of pip and pipx (`--extra` for `--extra-index-url`) and short options with their value in the same word (`-ihttps://…`), so an option, or a variable such as `PIP_INDEX_URL` or `UV_INDEX`, that points the tool at another index makes it ask. Command names count as the tool resolves them: npm's unique abbreviations and camelCase spellings (`npm exe` is `npm exec`, `npm installTest` is `npm install-test`, `npm ini` and `npm cre` are `npm init`), Bun's aliases `bun a` and `bun c`, and Composer's short names (below); a word npm finds more than one command for (`npm ex`: `exec`, `explain`, `explore`) runs nothing, so it goes through. The options of a download command count where its tool reads them: before the subcommand (`pnpm --package=x dlx y`, `pnpm --package x -c dlx '…'`, `npm --package=x exec y`, `yarn -p x dlx y`, and `bun --package=x x y`, where bun reads only a value in the same word) and, with `npm exec` and `npm x`, among the arguments up to `--` (`npm exec y --package=x` downloads `x` and runs `y`), where a word the shell expands may be an option of npm, so it makes the hook ask unless it comes after `--`. Such a variable, and one that weakens the policy or changes the version or the directory, counts wherever the line sets it, as if it were set to the value that weakens the policy most: `NAME=value` is read with its value, and a value depcurfew cannot read counts as the worst one when a builtin sets the name (`printf -v NAME`, `read NAME`, `mapfile` and `readarray`, `getopts … NAME`, `wait -p NAME`, a nameref `declare -n ref=NAME`, zsh's and ksh's `print -v`, `vared` and `integer`), with `NAME+=…`, `NAME[0]=…`, `for NAME in …`, `select`, `${NAME:=…}`, an assignment in arithmetic (`let`, `(( NAME = … ))`, `NAME++`, `[[ … -eq … ]]`, subscripts, `declare -i`), inside `eval`, an alias, a `trap`, `sh -c` or `$(…)`, in the value of another variable (`N=NAME`), which the shell may use as a name, in the code of `python -c`, `node -e` or `php -r`, which depcurfew does not read, and with cmd's `set /p` and `set /a`; so does a builtin that may set a variable whose name the shell builds (`export "$(cat .env)"`). A word that only reads or mentions the name sets nothing: `printenv NAME`, `echo`, `grep NAME`, a commit message or `Composer` in a sentence, and `export NAME`, `readonly NAME` and `unset NAME`, which do not change a value. Names count in the case the tool reads them: in Bash on Linux and macOS `COMPOSER` but not `Composer`, `npm_config_*` and `YARN_*` in any case, `pnpm_config_*` all in lower or all in upper case, `PIP_` followed by the rest in any case; in PowerShell, cmd and Git Bash on Windows every case counts. The hook then asks only where that value would make it ask: a variable of the registry or of the version next to an install that names packages (`read npm_config_registry; npm ci` goes through like `npm_config_registry=… npm ci`, which installs the lockfile), one that weakens the release-age delay or the install scripts when the policy sets them, and one that turns off a protection of Composer next to any command of a package manager. It checks the version the command would install: the dist-tag `--tag` or `npm_config_tag` makes npm prefer, pre-releases that `--pre`, `--prerelease allow` and the like allow, and builds from source that the command's options or variables force (wheels refused), which count as install scripts. A script piped into an interpreter (`curl … | python3`, `irm … | iex`) or read from `<(curl …)` makes it ask, and so does a command with more than 50 packages, of which the first 50 are checked. `deno x` and `dx` are read like `npx`. `npm link` with package names is checked like `npm install`, since npm installs each name the global space lacks from the registry before it links it; a name the registry does not have is only a warning, because npm then links the package of that name that is linked globally, or fails. `pipx inject <environment> <package>…` is checked like `pipx install`, and `pipx runpip <environment> …` like the `pip` command it runs (a lone argument with blanks split into words as pipx splits it); `pipx inject -r`, `pipx install-all` and `pipx manifest sync` make it ask, because they install the packages a file lists into pipx environments outside the project, which no configuration of the project covers. Packages from requirements files (`pip install -r`) and the project's own files (`uv sync`, `poetry install`) are not checked, as with `npm install` without names; `depcurfew doctor` checks the lockfiles. When it cannot read the hook event at all (not JSON, not an object, or a `PreToolUse` event of Bash, PowerShell or Monitor without a readable `tool_input.command`, apart from a Monitor WebSocket watch), it exits with code 2, which blocks the command in every Claude Code version, and says on stderr what it got (with the code `hook-event-unreadable`) and how Claude Code runs the hook.
726
877
 
727
878
  The registry settings of Yarn (`npmRegistryServer` and `npmScopes` in `.yarnrc.yml`), Bun (`registry` and `scopes` in the `[install]` table of `bunfig.toml`), uv (`[[tool.uv.index]]` or `[[index]]`, `index-url`, `extra-index-url` and `find-links` in `pyproject.toml` or `uv.toml`), Poetry (`[[tool.poetry.source]]`), PDM (`[[tool.pdm.source]]`) and Pipenv (`[[source]]` in `Pipfile`), in the working directory or a directory above it in the project, make the hook ask about the packages that tool would install from there when they name another registry or index than the one depcurfew checked, or a value it cannot read (such as `${NPM_REGISTRY}`). Pass that registry to the hook with `--registry` (Python: `--index`) when you trust it; settings that add places to install from (`extra-index-url`, `find-links`, a uv index without `default = true`, a supplemental Poetry source, a PDM source not named `pypi`) always make it ask, because depcurfew checks one index. An index that serves only the packages pinned to it (uv's `explicit = true` with `[tool.uv.sources]`, Poetry's `explicit` source with `source =` in a dependency, a `Pipfile` source other than the first with `index =`) makes it ask about those packages. When such a setting applies and the checked registry or index has no such package or version, the hook asks and names the setting instead of denying the install as a made-up name. The same goes for an `.npmrc` that npm reads there but `check` does not take (see above).
728
879
 
@@ -795,18 +946,18 @@ The matcher `Bash|PowerShell|Monitor` covers every tool that runs shell commands
795
946
  - **Cloud sessions (Claude Code on the web):** the repository's `.claude/settings.json` hooks run there, user-level ones do not. The environment's setup script runs before Claude Code starts, so it can install depcurfew too; the `SessionStart` hook above covers a cached environment as well.
796
947
  - **Locked out already:** install depcurfew from a terminal outside Claude Code, or remove the hook from `.claude/settings.json` there; Claude cannot do it for you while the hook blocks its commands, which is the point of `block`.
797
948
 
798
- Commands the guard reads (an add subcommand of any npm package manager, such as `install` or `i`, is checked for all of them; Python commands are matched word for word, and `python -m pip` counts as `pip`):
949
+ Commands the guard reads (an add subcommand of any npm package manager, such as `install` or `i`, is checked for all of them, while `npm link` counts for npm alone; npm commands also count abbreviated or in camelCase, as npm resolves them (`npm exe`, `npm installTest`); pnpm, Yarn and Bun take their command names only as written, with the aliases in the table; Python commands are matched word for word, and `python -m pip` counts as `pip`):
799
950
 
800
951
  | Package manager | Add packages | Download and run | Starter kits |
801
952
  | --- | --- | --- | --- |
802
- | npm | `npm install`, `npm add`, `npm i`, `npm in`, `npm ins`, `npm inst`, `npm insta`, `npm instal`, `npm isnt`, `npm isnta`, `npm isntal`, `npm isntall`, `npm install-test`, `npm it` | `npx`, `npm exec`, `npm x` | `npm init`, `npm create`, `npm innit` |
953
+ | npm | `npm install`, `npm add`, `npm i`, `npm in`, `npm ins`, `npm inst`, `npm insta`, `npm instal`, `npm isnt`, `npm isnta`, `npm isntal`, `npm isntall`, `npm install-test`, `npm it`, `npm link`, `npm ln` | `npx`, `npm exec`, `npm x` | `npm init`, `npm create`, `npm innit` |
803
954
  | pnpm | `pnpm add` | `pnx`, `pnpx`, `pnpm dlx` | `pnpm create` |
804
955
  | Yarn (Berry) | `yarn add` | `yarn dlx` | `yarn create` |
805
- | Bun | `bun add` | `bunx`, `bun x` | `bun create` |
956
+ | Bun | `bun add`, `bun a` | `bunx`, `bun x` | `bun create`, `bun c` |
806
957
  | Deno | `deno add`, `deno install` | `deno x`, `dx`, `denox`, `dnx` | none |
807
958
  | uv | `uv add`, `uv pip install`, `uv tool install` | `uvx`, `uv tool run`, `uv run` | none |
808
959
  | pip | `pip install`, `pip3 install` | none | none |
809
- | pipx | `pipx install` | `pipx run` | none |
960
+ | pipx | `pipx install`, `pipx inject` | `pipx run` | none |
810
961
  | Poetry | `poetry add` | none | none |
811
962
  | PDM | `pdm add` | none | none |
812
963
  | Pipenv | `pipenv install` | none | none |
@@ -819,7 +970,7 @@ The hook reads commands the way a shell splits them, but it is a safety net, not
819
970
 
820
971
  `depcurfew mcp` runs the same check as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so that an AI agent in Claude Desktop, VS Code or any other client that starts a local stdio server can ask before it installs a package. It offers two read-only tools:
821
972
 
822
- - `check_packages` takes `packages`, a list of 1 to 50 specs as `depcurfew check` takes them (`zod@4`, `pypi:requests`, `composer:monolog/monolog`), and an optional `directory`. It returns the document of `check --json` (`schemaVersion`, `policy`, `packages` with their findings, `notices`, `errors`) as structured content and as JSON text. A package that breaks the policy is a successful check with error findings; the call itself is an error (`isError`) only when nothing could be checked, such as a `depcurfew.json` that cannot be used, a directory that does not exist or a wrong argument, with a message that says what to do.
973
+ - `check_packages` takes `packages`, a list of 1 to 50 specs as `depcurfew check` takes them (`zod@4`, `pypi:requests`, `composer:monolog/monolog`), and an optional `directory`. It returns the document of `check --json` (`schemaVersion`, `policy`, `packages` with their findings, `notices`, `errors`) as structured content and as JSON text. A package that breaks the policy is a successful check with error findings; the call itself is an error (`isError`) only when nothing could be checked, such as a `depcurfew.json` that cannot be used, a directory that does not exist or a wrong argument, with a message that says what to do. Even then an exact version that the local OSV.dev data lists is in `packages` as `known-malicious`; with more than 50 specs the result is then that document, with the warning `too-many-packages` in `notices`, instead of the message (see [Known malicious versions](#known-malicious-versions-osv)).
823
974
  - `explain_policy` takes an optional `directory` and returns which `depcurfew.json` applies (`null` and the default policy of `depcurfew init` when there is none), the release-age delay and its exceptions, the install-script lists, the allowed sources, and the registry and index `check_packages` asks, with the scopes `.npmrc` assigns to other registries as a list of `scope` and `registry`, `composerRepository`, the Packagist URL it asks about Composer packages, and `osv`, the OSV.dev endpoint it asks with `--osv`. It reads local files only.
824
975
 
825
976
  The policies are those the hook applies, with `directory` as the working directory: the `depcurfew.json` of the project directory (or the nearest one above it up to the root of the repository), and a different one nearer `directory` too, the stricter answer counting, so that a `depcurfew.json` the user did not write, such as one in a package in `.venv/…/site-packages`, a vendored clone or a file an agent wrote, can tighten the check but not relax it; a notice names it. Without a `depcurfew.json` in the project directory, the nearest one from `directory` applies. Paths are shown relative to the project directory, and a `directory` must exist inside it. The registries come from `.npmrc` as for `check`: an `.npmrc` that npm reads in `directory` but that does not belong to the policy makes the packages it applies to unverified, and so does a root `composer.json` there that declares other repositories for Composer packages.
@@ -896,17 +1047,19 @@ Claude Code sets `CLAUDE_PROJECT_DIR` to the project root for the server, and `d
896
1047
 
897
1048
  Other clients that start a stdio server take the same command and arguments; give them absolute paths and `--dir` when they do not start the server in the project directory.
898
1049
 
899
- With `--osv` in the server's arguments (`"args": [".../cli.js", "mcp", "--osv"]`), `check_packages` also looks the versions up in OSV.dev, as `check --osv` does, and `explain_policy` names the endpoint in `lookups.osv` (`null` without it). An agent cannot turn it on or off: it is an option of the server, not an argument of a call.
1050
+ With `--osv` in the server's arguments (`"args": [".../cli.js", "mcp", "--osv"]`), `check_packages` also looks the versions up in OSV.dev, as `check --osv` does, and `explain_policy` names the endpoint in `lookups.osv` (`null` without it). An agent cannot turn it on or off: it is an option of the server, not an argument of a call. Without `--osv`, `check_packages` looks the versions up in the local OSV.dev data of `depcurfew osv update`, read afresh for each call, and its notices say when that data is old.
900
1051
 
901
1052
  The server is advice to an agent that asks, not a guard: an agent can install packages without calling it. The package manager configuration from `depcurfew apply` is what enforces the policy; in Claude Code the hook above also checks every install command.
902
1053
 
903
1054
  ## Network and privacy
904
1055
 
905
- Only `check`, the Claude Code hook and the `check_packages` tool of `depcurfew mcp` use the network, and `doctor` and the GitHub Action only with `--osv` (input `osv: true`), for OSV.dev alone. For each package the first three request one URL with the package name in it: from the public npm registry (`registry.npmjs.org`), PyPI (`pypi.org`) or Packagist (`repo.packagist.org`), or from the registry that the project's or your `.npmrc` sets, `--registry` or `--index`. They send no credentials (tokens in `.npmrc` are not read, and a user name and password written into a registry URL are removed before the request and in every message) and nothing else about the project, and depcurfew has no telemetry. A private package whose scope has no registry in an `.npmrc` that npm reads is looked up in the public registry, so its name reaches that registry; set `@scope:registry` in `.npmrc` or pass `--registry` first. A package whose scope has a registry in an `.npmrc` that `check` does not take, or in `.yarnrc.yml` or `bunfig.toml`, is not looked up, and neither is a Composer package when the root `composer.json` declares another repository or turns packagist.org off. With `--osv` they also send the name, ecosystem and version of each package to `api.osv.dev` in a batch query, and `doctor --osv` those of every locked package version, private packages included (see [Known malicious versions](#known-malicious-versions-osv)); without `--osv` nothing goes to OSV.dev. `init`, `explain`, `apply` and `doctor` without `--osv` work without network access. The GitHub Action checks the repository without it too, unless the input `osv` is `true`; afterwards it sends the result (the counts, the table of checks and findings, the files read) to the GitHub API of the run (`GITHUB_API_URL`) with the workflow token to create the depcurfew doctor check (input `check-run`), and the SARIF upload to code scanning is a separate step that runs GitHub's own action. An editor that reads the `$schema` of `depcurfew.json` may download the schema from unpkg.com.
1056
+ Only `check`, the Claude Code hook and the `check_packages` tool of `depcurfew mcp` use the network, `doctor` and the GitHub Action only with `--osv` (input `osv: true`), for OSV.dev alone, and `depcurfew osv update` only to download the public export files of OSV.dev from `storage.googleapis.com`, a request that holds nothing about you or the project. The lookups in the local data it writes need no network. For each package the first three request one URL with the package name in it: from the public npm registry (`registry.npmjs.org`), PyPI (`pypi.org`) or Packagist (`repo.packagist.org`), or from the registry that the project's or your `.npmrc` sets, `--registry` or `--index`. They send no credentials (tokens in `.npmrc` are not read, and the user part of a registry URL, a password with an unencoded `@` and a token alone included, is removed before the request) and nothing else about the project, and depcurfew has no telemetry. No output of depcurfew shows credentials from a URL either: text, JSON, SARIF, diffs, the hook's answer, `depcurfew mcp` and what the GitHub Action sends show the user part of a URL as `<redacted>`. A private package whose scope has no registry in an `.npmrc` that npm reads is looked up in the public registry, so its name reaches that registry; set `@scope:registry` in `.npmrc` or pass `--registry` first. A package whose scope has a registry in an `.npmrc` that `check` does not take, or in `.yarnrc.yml` or `bunfig.toml`, or one that the `.npmrc` depcurfew takes assigns to another registry than the one it asks, is not looked up, and neither is a package that a registry or index of the environment or of a user or global configuration file points elsewhere (see [Guarding installs](#guarding-installs)), nor a Composer package when the root `composer.json` declares another repository or turns packagist.org off. With `--osv` they also send the name, ecosystem and version of each package to `api.osv.dev` in a batch query, and `doctor --osv` those of every locked package version, private packages included (see [Known malicious versions](#known-malicious-versions-osv)); without `--osv` nothing goes to OSV.dev. `init`, `explain`, `apply` and `doctor` without `--osv` work without network access. The GitHub Action checks the repository without it too, unless the input `osv` is `true`; afterwards it sends the result (the counts, the table of checks and findings, the files read) to the GitHub API of the run (`GITHUB_API_URL`) with the workflow token to create the depcurfew doctor check (input `check-run`), and the SARIF upload to code scanning is a separate step that runs GitHub's own action. An editor that reads the `$schema` of `depcurfew.json` may download the schema from unpkg.com.
1057
+
1058
+ depcurfew uses the `fetch` of Node.js, which goes through the proxy of `HTTPS_PROXY` and `HTTP_PROXY` only when `NODE_USE_ENV_PROXY=1` is set (or Node.js runs with `--use-env-proxy`), from Node.js 22.21.0 and 24.0.0; older Node.js has no such switch. Without it, on a network that is reached only through a proxy, the registry cannot be reached: `check` exits with 1 and the hook asks, as for any lookup that fails, and `depcurfew osv update` keeps the data it has.
906
1059
 
907
1060
  ## Library
908
1061
 
909
- The same functions are available from the package: `detectProject`, `planApply` and `applyProject`, `runDoctor` with `toSarif` (and `runDoctorWithOsv`, `doctor --osv`), `checkSpecs` with `scanCommandLine`, `queryOsv`, and the policy parser and converters. Their types ship in `dist/index.d.ts`, and [`src/index.ts`](https://github.com/DevXord/depcurfew/blob/main/src/index.ts) lists every export. The package is an ES module only: use `import`; `require('depcurfew')` works on Node.js 22.12 and later, which load ES modules with `require()`.
1062
+ The same functions are available from the package: `detectProject`, `planApply` and `applyProject`, `runDoctor` with `toSarif` (and `runDoctorWithOsv`, `doctor --osv`, and `runDoctorWithLocalOsv` with `LocalOsvData`, the local OSV.dev data, and `updateOsvData`, `depcurfew osv update`), `checkSpecs` with `scanCommandLine`, `queryOsv`, and the policy parser and converters. `runDoctor` compares a repository without `depcurfew.json` with a policy, as `depcurfew doctor` does, only when it gets one as the option `defaultPolicy` (for example the `policy` that `parsePolicy(renderPolicy(DEFAULT_POLICY))` returns); without it the rules that need a policy do not run. Their types ship in `dist/index.d.ts`, and [`src/index.ts`](https://github.com/DevXord/depcurfew/blob/main/src/index.ts) lists every export. The package is an ES module only: use `import`; `require('depcurfew')` works on Node.js 22.12 and later, which load ES modules with `require()`.
910
1063
 
911
1064
  ## Contributing
912
1065