Skip to content

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 bare pip and /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 install or pipxx as instances of pip, 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 implementation
  • plugins/core-standards/tests/test-venv-guard.sh — 25 test cases covering ALLOW / BLOCK / PASS classifications plus word-boundary edges
  • configs/security/baseline-settings.json — Python deny rules
  • configs/security/trusted-hooks.yaml — SHA-256 registry