title: Venv-Enforcement Pattern — Block Bare pip, Allow .venv/bin/pip
symptoms:
- Non-developers running AI-generated Python scripts pollute global Python
- pip install <pkg> runs outside any virtualenv, leaving global dependency drift
- Supply-chain risk: unvetted PyPI packages installed into system Python
- Deny rule on pip install* is too coarse and blocks legitimate .venv/bin/pip install
- Word-boundary false positives: pipx install or pipxx incorrectly matched as pip
root_cause: |
A flat Bash(pip install*) deny rule cannot distinguish bare pip (bad)
from .venv/bin/pip (good). Allowing both loses the safety guarantee;
denying both breaks the legitimate workflow. A single layer of defense
(deny rule OR hook) also provides no redundancy — a bypass in one means
global pollution succeeds silently.
resolution_type: pattern
severity: high
module: plugins/core-standards
problem_type: security
date: 2026-04-21
spec: SPEC-115
tags:
- security
- claude-code
- hooks
- deny-rules
- python
- venv
- supply-chain
- defense-in-depth
- compound-pattern
Venv-Enforcement Pattern — Block Bare pip, Allow .venv/bin/pip¶
Problem¶
Non-developer users ran Claude-generated Python code with bare pip install,
polluting system Python with unvetted PyPI dependencies. The /vt-c-npm-security
skill already covered JavaScript, but Python had no equivalent — leaving a gap
where "please write me a Python script" became a supply-chain attack surface.
A naive fix — adding Bash(pip install*) to the deny list — blocks legitimate
.venv/bin/pip install inside a project virtualenv too, breaking the intended
developer workflow. And a single-layer defense (deny rule OR hook, not both)
means one bypass defeats the guarantee.
Root Cause¶
- Flat pattern matching is insufficient:
pip install*matches both barepipand/path/to/.venv/bin/pip. The toolchain path prefix carries the semantic signal (venv vs global), not the command name. - Single-layer defense is brittle: A deny rule can be disabled; a hook can be skipped. Two independent layers mean bypassing one still triggers the other.
- Word-boundary errors: Substring matching treats
pipx installorpipxxas instances ofpip, producing false positives that erode trust in the guardrail.
Resolution¶
Three-layer defense:
Layer 1 — Deny rules (baseline-settings.json)¶
Extend the Claude Code baseline deny list with Python-specific patterns:
{
"Bash(pip install*)": "deny",
"Bash(pip3 install*)": "deny",
"Bash(pip install --user*)": "deny",
"Bash(python -m pip install*)": "deny",
"Bash(python3 -m pip install*)": "deny"
}
Coarse-grained — blocks the command name path. Defense-in-depth starts here
but cannot distinguish .venv/bin/pip from pip. That's Layer 2's job.
Layer 2 — PreToolUse hook (venv-guard.sh)¶
A Bash hook classifies each Bash(...) tool call into three buckets:
| Classification | Examples | Action |
|---|---|---|
| ALLOW (explicit safe) | .venv/bin/pip install, venv/bin/pip3, python -m venv, pipx install |
exit 0 |
| BLOCK (explicit unsafe) | bare pip install, pip3 install, python -m pip install |
exit 2 with JSON reason |
| PASS (unknown) | pipxx, pip-tools, helm |
exit 0 — default-allow |
Critical: use word-boundary matching ([[:<:]]pip[[:>:]]), not substring.
pipxx must not match pip, and pipx must match its own ALLOW rule before
the pip BLOCK rule runs. Ordering matters.
Layer 3 — Hook integrity (trusted-hooks.yaml)¶
Pin the hook script's SHA-256 in plugins/core-standards/configs/trusted-hooks.yaml
so tampering is detected on load:
- path: plugins/core-standards/scripts/venv-guard.sh
sha256: 647f70125dcf2c28a239bbc85d737c9f325f05afc2c1946db99514dee9f60b81
When shasum tooling is permission-gated (e.g. during review in a fork context),
fall back to source-invariance proof: if the hook has a single commit and
no modification since, the registered SHA is still authoritative. git log --
<path> with one entry is the check.
Prevention¶
- Always deploy all three layers together when hardening a toolchain. One layer is a suggestion; three layers is a guarantee.
- Prefer path-based allowlisting over command-name denying when the
command name is reused by both safe and unsafe invocations (like
pip). - Test word-boundary edge cases — write explicit tests for
pipx,pipxx,pip-tools, and any lookalikes before shipping. SPEC-115's Test 24 (pipxx → exit 0) came from a tasks.md/contract disagreement; contract was authoritative. - Document deferred post-merge verifications in
state.yaml. A hook added in the same session it's tested cannot actually fire — defer the dynamic check to a fresh session.
Generalization¶
The same pattern applies to any language with a global-vs-project toolchain split:
| Language | Global (BLOCK) | Project (ALLOW) |
|---|---|---|
| Python | pip install |
.venv/bin/pip install |
| Node | npm install -g, yarn global add |
npm install (in project dir) |
| Ruby | gem install |
bundle install |
| Rust | cargo install --global (n/a by default) |
cargo build |
| Go | go install to $GOPATH/bin |
go build in module dir |
Rule of thumb: if the unsafe variant differs from the safe variant only by an installation target (not the command), use a path-prefix hook — not a flat command deny rule.
References¶
- SPEC-115: Python Security Posture
- SPEC-114: Security Baseline Hardening (the preceding deny-rule + hook work this builds on)
plugins/core-standards/scripts/venv-guard.sh— reference implementationplugins/core-standards/tests/test-venv-guard.sh— 25 test cases covering ALLOW / BLOCK / PASS classifications plus word-boundary edgesconfigs/security/baseline-settings.json— Python deny rulesconfigs/security/trusted-hooks.yaml— SHA-256 registry