depcurfew v1.6.1

Claude Code

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:

  • 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) (Claude sees the reason and can pick another package or version);
  • 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);
  • nothing when everything checks out, so the command goes through Claude Code's usual permission prompts.

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.

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. Bun's options count as Bun 1.4.4 reads them: bunx and bun x skip the options of npm, pnpm and Yarn together with their value (bunx --tag next cowsay runs cowsay), and bun add takes the values of --cpu, --os and --linker (bun add --cpu x64 left-pad installs left-pad); where Bun 1.3.0 to 1.4.3 took the word after such an option for a package (--tag next, --libc musl), that word is checked too, and one the registry does not have is only a warning, since such an older Bun fails on it; write the value in the same word (--tag=next) to leave it out. 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.

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).

The hook takes its policy from the project Claude Code works on (CLAUDE_PROJECT_DIR), wherever Claude's working directory is inside it: when the project directory has a depcurfew.json (or one above it up to the root of the repository), that policy applies, and a different depcurfew.json nearer the working directory (in a nested repository, a vendored one or a package folder) is applied too, and the stricter answer counts, so it can tighten the check but never relax it; a decision says so. This holds whether or not the project is in a git repository. When the project directory has no depcurfew.json, the nearest one of the working directory applies alone, as check --dir finds it, so frontend/depcurfew.json decides for frontend. Paths are compared by where they really lead, through symlinks.

Each install is checked in the directory it runs in, not only in Claude's working directory: the hook follows cd (also quoted, absolute or after &&, ;, || or a new line), pushd, popd, cd - and zsh's chdir, PowerShell's Set-Location (cd, chdir, sl), Push-Location and Pop-Location with a written-out -Path or -LiteralPath, cmd's cd /d, and the options that move one command: npm --prefix (-C), pnpm --dir (-C), yarn --cwd, bun --cwd, uv --directory and --project (and UV_WORKING_DIR, UV_PROJECT before the command), poetry --directory (-C) and --project (-P), pdm --project (-p, PDM_PROJECT), and PIPENV_PIPFILE (the directory of that Pipfile). A change ends with its subshell ( … ), $( … ) or sh -c '…'. So cd pkgs/strict && npm install x is checked with the policy of the project and pkgs/strict/depcurfew.json as if Claude were in pkgs/strict, with the .npmrc, .yarnrc.yml, bunfig.toml and uv settings of that directory. A directory outside the project gets only the project's policy (or, when the project has none, the nearest one from the working directory), never one found there. When the hook cannot tell the directory, as with cd "$DIR", cd ~, cd alone, a pattern, a cd in a loop, a function or one branch of if, it asks about an install that names packages and says to run the install from that directory or with the path written out; a command that installs nothing, or only from the lockfile (npm ci), goes through as before. A change of directory can only tighten the check: the policies of the working directory apply to an install in another directory too, in case the change does not happen. A cd to a directory that does not exist (unless a mkdir earlier in the command creates it) or is a file, cd - after the command sets OLDPWD, and a relative cd after it sets PWD make the hook ask; cd -P, set -P and set -o physical are followed through symlinks. env -C (--chdir) and sudo -D move the install too, also inside an env -S string, which is read again as arguments as GNU env reads it, while find -execdir and an install in a trap that a later cd may move make it ask. The stack of pushd is followed as bash keeps it (pushd -n, +N and -N, popd -n, dirs -c; a popd on an empty stack stays where it is), and a directory that cd - or popd leaves must exist. The hook also asks about an install after a builtin that may set OLDPWD, PWD, CDPATH or DIRSTACK through a name the shell builds (declare "${X}PWD=…", printf -v, read, eval "$X") followed by cd -, popd or a relative cd, and after a trap on DEBUG, ERR, RETURN or a signal that changes directory, a file run with . or source, or a function whose body changes directory. The activation script of a Python virtual environment (source .venv/bin/activate, bin/activate.fish, bin/activate.csh, Scripts\Activate.ps1) keeps the directory known when its environment directory holds pyvenv.cfg and no command before it on the line may write the script. PowerShell paths with \ (cd pkgs\strict) are read with \ as a separator on every system. A Python install of . or .[extras] (pip install -e '.[dev]') is the project itself only when it runs in the directory of depcurfew.json (without depcurfew.json, the project directory) or of one of its projects; anywhere else, as after cd vendor/lib, in a subdirectory of the project that projects does not list (such as a workspace member), or in a directory the hook cannot tell, it installs a local directory, which sources.directory decides, and the message names that directory.

Composer commands are read as Composer reads them: options before or after the command, of which only -d and --working-dir take the next word before the command name (Composer finds the command name before it knows the command, so composer --ignore-platform-req require x/y is composer require x/y), the alias r, the short names Composer accepts when only one command starts with them (composer req, but not composer re), and php composer.phar …, php /path/to/composer … and ./composer.phar …. -d and --working-dir move the command, like cd, as Composer finds them: the first word that is one of them, or starts with -d or --working-dir=, before --; a grouped -nd dir does not move Composer. The scripts of the root composer.json are commands too when the command word is not the exact name of a Composer command: a word that names a script (or one of its scripts-aliases) runs that script, as composer run-script does, which the hook does not follow (as with npm run), and an abbreviation that a script makes ambiguous (composer req next to a script required) runs nothing. Composer reads scripts-aliases only since 2.7.0, so a word that is a Composer command without them (composer r next to a script alias r, which Composer 2.2 to 2.6 run as composer require) is checked as that command. A composer.json above the directory the command runs in may not apply (see check above), so the hook reads the command word also without its scripts (composer req x in a subdirectory is composer require x there) and checks the version also at the stability stable. composer require and composer global require are checked package by package, also when the constraint is the next word (composer require vendor/name ^1.0); composer create-project checks the package of the project (with its version and --stability) and the packages of --require; composer update checks the packages it names with a constraint (vendor/name:1.2.3, --with vendor/name:1.2.3) and the ones it names without one at the constraint of the root composer.json (with its minimum-stability and prefer-stable), while a package that composer.json does not require, a pattern ("vendor/*") and a full composer update are left to Composer, as composer install, which installs composer.lock. The hook asks about --no-blocking, --no-security-blocking and COMPOSER_NO_BLOCKING or COMPOSER_NO_SECURITY_BLOCKING with any value but 0 and an empty one (they turn off every policy block of Composer, malware blocking included), COMPOSER_POLICY=0, COMPOSER_POLICY_MALWARE_BLOCK=0 and create-project --no-secure-http whatever the policy says, and about --no-cooldown, COMPOSER_POLICY_COOLDOWN_BLOCK=0 and a COMPOSER_POLICY_COOLDOWN_PERIOD shorter than cooldown.minimum; about composer config that changes a setting (--unset, --editor, also with --global or after composer global), composer repository add (and remove, set-url, enable, disable) and composer policy add-source in the same command as another Composer command; about such a change with --global (-g) or --file (-f), or after composer global, also without another Composer command, because the Composer commands that follow use COMPOSER_HOME/config.json, COMPOSER_HOME/composer.json or that file, which depcurfew does not read; and about COMPOSER=<file>, COMPOSER_HOME=<directory> and create-project --repository, which take the repositories from elsewhere, and about composer global update of a package named without a constraint, whose constraint is in COMPOSER_HOME/composer.json. Commands of composer global and create-project do not read the composer.json of the working directory, so its repositories do not count for them.

It reads commands the way Windows runs them too: executables in any case and with .exe, .cmd, .bat or .ps1 (NPM.CMD i lodash), paths with backslashes, and cmd /c or cmd /k (also //c from Git Bash), where %VAR% and !VAR! count as expansions. PowerShell commands (the PowerShell tool, powershell -c, pwsh -c and iex) are read like any other when they are plain commands: quotes, ;, |, &&, the call operator &, redirections and $env:NAME = "value" before a command. PowerShell syntax it does not read, such as variables, (...), $(...), {...}, @(...), backticks, commas, Start-Process or Set-Alias, makes it ask when the same statement names a package manager; a statement without one, such as Get-ChildItem | Where-Object { $_.Length -gt 1kb }, goes through. Variables that point a package manager at another registry or weaken its checks count wherever the line sets or names them: $env:NAME, ${env:NAME}, paths on the Env: drive, the names New-Item -Name, Rename-Item -NewName, Copy-Item and Move-Item give there, and [Environment]::SetEnvironmentVariable, with parameters read as PowerShell binds them (any case, abbreviations such as -N and -Nam, -Name:value, positions) and backtick escapes and quotes removed (Env:COMPO`SER is Env:COMPOSER). A use of the Env: drive whose variable name it cannot tell (Set-Item "Env:$name", a wildcard, splatting, a parameter it cannot bind) makes it ask when the line runs a package manager. COMPOSER counts only as such a name, not as the word composer (& 'composer' require …, Write-Output 'composer'). It also asks about an argument with &, |, <, > or " for a package manager run from PowerShell, because the .cmd shim of a package manager hands its arguments to cmd.exe again.

Install depcurfew as a development dependency of the project, then add the hook:

In .claude/settings.json of the project:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell|Monitor",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": [
              "${CLAUDE_PROJECT_DIR}/node_modules/depcurfew/dist/cli.js",
              "check",
              "--hook",
              "claude-code"
            ],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

The matcher Bash|PowerShell|Monitor covers every tool that runs shell commands. On Windows, PowerShell is the main shell wherever the PowerShell tool is on, and without Git Bash there is no Bash tool, so a hook on Bash alone never runs there; Monitor runs a command in the background. command is node itself, which also starts on Windows, where Claude Code runs it without a shell.

onFailure: "block" needs Claude Code 2.1.295 or later; without it, a hook that fails to start, crashes or times out lets the command through.

Before depcurfew is installed. The hook runs the copy in node_modules. When that file is missing (a fresh clone before npm ci, a repository without package.json in its root, a new cloud session), node still starts, cannot load it and exits with code 1 and a stack trace, and with onFailure: "block" that failure blocks every command of these tools, including the npm ci that would install depcurfew; without it, every command runs unchecked. The blocked call shows the hook command, failed; blocking because onFailure is "block" and node's Error: Cannot find module '…/node_modules/depcurfew/dist/cli.js': run the install (npm ci --ignore-scripts in the project, or in .claude/depcurfew for the tools directory below) from a terminal, then try again. So install depcurfew before the hook is needed:

  • On your machine: run npm ci once after cloning, in a terminal, before you start Claude Code.
  • In every session: a SessionStart hook installs the locked copy when it is missing (with pnpm, Yarn or Bun, use their install from the lockfile instead of npm ci). It runs when a session starts or resumes, and Claude's first response waits for it, so the guard is in place before the first command. --ignore-scripts keeps the install from running dependency scripts before the guard is there. Add it next to PreToolUse in the same .claude/settings.json:
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "cd \"$CLAUDE_PROJECT_DIR\" && { test -f node_modules/depcurfew/dist/cli.js || npm ci --ignore-scripts; }"
          }
        ]
      }
    ]
  }
}

The command is a line for sh and Bash: a hook without shell runs in sh -c on macOS and Linux and in Git Bash on Windows, but in PowerShell on Windows when Git Bash is not installed, where this line fails and installs nothing, so with onFailure: "block" the guard then blocks every command. On Windows without Git Bash, install Git for Windows or run the install yourself before you start Claude Code.

  • A repository without package.json in its root (several projects side by side): keep depcurfew in the tools directory of In a repository without package.json, .claude/depcurfew/package.json with depcurfew pinned exactly and its package-lock.json committed, install it there (npm ci --ignore-scripts --prefix .claude/depcurfew, also in the SessionStart hook), and point the hook at ${CLAUDE_PROJECT_DIR}/.claude/depcurfew/node_modules/depcurfew/dist/cli.js.
  • 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.
  • 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.

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):

Package managerAdd packagesDownload and runStarter kits
npmnpm 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 lnnpx, npm exec, npm xnpm init, npm create, npm innit
pnpmpnpm add, pn addpnx, pnpx, pnpm dlx, pn dlxpnpm create, pn create
Yarn (Berry)yarn addyarn dlxyarn create
Bunbun add, bun abunx, bun xbun create, bun c
Denodeno add, deno installdeno x, dx, denoxnone
uvuv add, uv pip install, uv tool installuvx, uv tool run, uv runnone
pippip install, pip3 installnonenone
pipxpipx install, pipx injectpipx runnone
Poetrypoetry addnonenone
PDMpdm addnonenone
Pipenvpipenv installnonenone
Composercomposer require, composer r, composer global require, composer global rnonecomposer create-project

dnx counts for Deno as deno x only when one of its arguments is an npm: or jsr: spec (dnx npm:cowsay); any other dnx line is the tool runner of the .NET SDK (10.0.100 and later), which, like dotnet dnx, dotnet tool exec and dotnet tool execute, gets no decision, as depcurfew does not look NuGet tools up yet.

The hook reads commands the way a shell splits them, but it is a safety net, not a sandbox: a command can install packages in ways it does not recognize (a script, another tool). Keep the package manager configuration from depcurfew apply in place; it is what enforces the policy for every install.