A Claude Code hook that blocks pip install requests and tells the agent why
A coding agent that hits ModuleNotFoundError can run pip install requests and carry on. The script works on that machine. The package never reaches requirements.txt, so CI or the next server breaks on the same import. A rule in CLAUDE.md asking the agent not to do that is a request. A PreToolUse hook is a check that runs before the command does. Below is a 35-line one, the contract it depends on, and the case where it fails. The contract Claude Code runs a PreToolUse hook before a tool call and sends it the call as JSON on stdin, including tool_name and, for Bash, tool_input.command. Exit code 2 blocks the call, and the hook's stderr becomes the message Claude sees. Exit 0 with nothing on stdout approves nothing; the call goes on to the normal permission flow. A hook can also print JSON with a permissionDecision of allow, which skips the prompt. [src: https://code.claude.com/docs/en/hooks] The hook below only exits 0 or 2 and prints nothing to stdout, so it cannot widen what the agent may do; your permission settings stay in charge. The hook .claude/hooks/block_pip.py, standard library only: #!/usr/bin/env python3 """PreToolUse hook: block `pip install ` that skips the requirements file.""" import json, shlex, sys ALLOWED = {"-r", "--requirement", "-e", "--editable", "-c", "--constraint"} def reason(command: str) -> str | None: try: tokens = shlex.split(command) except ValueError: tokens = command.split() if tokens[:3] in (["python", "-m", "pip"], ["python3", "-m", "pip"]): tokens = tokens[2:] if tokens[:2] not in (["pip", "install"], ["pip3", "install"]): return None args = tokens[2:] if any(a in ALLOWED for a in args): return None pkgs = [a for a in args if not a.startswith("-")] if not pkgs or all(p.startswith((".", "/")) for p in pkgs): return None return (f"`pip install {' '.join(pkgs)}` blocked: add it to requirements.txt " "with a version pin, then run `pip install -r requirements.txt`.") def main() -> int: event = json.load(sys.stdin) command = event.get("tool_input", {}).get("command", "") why = reason(command) if why: print(why, file=sys.stderr) # Claude Code shows stderr to the model return 2 # exit 2 = block this tool call return 0 # exit 0 = normal permission flow if __name__ == "__main__": sys.exit(main()) The message is written for the agent, not for you. It says what was blocked and what to do instead, so the agent has a next step that is not another way around the block. Wire it in .claude/settings.json: { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block_pip.py" } ] } ] } } Test it without Claude Code The hook is a function from a string to a reason, so most of it is a plain unit test: from block_pip import reason def test_blocks_adhoc(): assert reason("pip install requests") assert reason("python3 -m pip install 'httpx==0.27.0'") def test_allows_lockfile_and_local(): for cmd in ("pip install -r requirements.txt", "pip install -e .", "pip install .", "pip list", "pytest -q"): assert reason(cmd) is None, cmd def test_misses_chained_command(): # one-line split only: the second command is not seen assert reason("cd app && pip install requests") is None And the whole hook from the shell, with a fake payload: $ echo '{"tool_name":"Bash","tool_input":{"command":"pip install requests"}}' | python3 .claude/hooks/block_pip.py `pip install requests` blocked: add it to requirements.txt with a version pin, then run `pip install -r requirements.txt`. $ echo $? 2 Then once in Claude Code itself. We ran it headless (claude -p, Claude Code 2.1.312), told to run pip install requests and not to try another install method. It was blocked, and the agent's reply quoted the hook's message word for word. The case it misses The third test is there on purpose. cd app && pip install requests is one string to this hook: the first token is cd, so it passes. The fix is to split the line on &&, ||, ;, | and newlines before checking each part, and to skip leading FOO=1 assignments and sudo. Our paid kit's guard_bash.py does that. Neither version sees uv pip install, pipx, or a command hidden in a script file or eval. A hook that reads command text is a guard against the common mistake, not a sandbox. Where this came from The same pattern, extended to five command types (commit while a secrets linter finds a key, --no-verify, force-push with --force-with-lease allowed, rm -r on /, ~ or the project root, and ad-hoc pip install), is the guard hook in our Agent Ops Kit ($29 one-time). The storefront shows a capture of it blocking real commands, unedited except for line wrapping. The free, MIT-licensed agent-ops-kit-lite plugin has the CLAUDE.md scaffold, the secrets linter and a /verify-claims skill. Written by Calyx AI, a product line of Quality Tester Solutions LLC. Drafted with AI assistance; the code and outputs were run on Python 3.14.8 and Claude Code 2.1.312.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to