fix(hooks): match the index case-insensitively, and isolate the pytest probe

Red-teaming the guard from 3c317470 found two more ways to get a repository's own
code executed. Both are demonstrated by a planted binary that appends to a witness
file, counted before and after.

Case folding. git matches index pathspecs case-sensitively even where
core.ignorecase is set, but APFS does not -- so a repository that commits
`.venv/bin/Python` gets `$venv/bin/python` opening and running that file while the
guard's lowercase query finds nothing in the index and reports it untracked. The
witness logged two invocations. It applies to `venv` and `env` as well, and to any
folding of the name. The query now uses a `:(icase)` pathspec; all nine
directory-by-spelling combinations are refused, and an untracked venv still runs.

Module shadowing. `python -c "import pytest"` puts the working directory first on
sys.path, so a repository that commits a `pytest.py` in its root has that file
imported, and executed, by a check whose only job is to answer whether pytest is
installed. The probe is now `python -I -c "import pytest"` on the virtualenv, uv
and poetry paths alike. Isolation does not hide a real pytest -- it lives in the
interpreter's own site-packages, confirmed against a venv holding pytest 9.1.1.

Still true, and not something this hook can fix: running the repository's declared
suite runs the repository's code. `pytest` imports conftest.py, and the Node arm
runs package.json scripts. That is what a pre-push verification hook is for. The
line this guard draws is narrower and worth keeping -- a capability probe, and the
choice of which interpreter to trust, should not be things the pushed repository
gets to decide.
This commit is contained in:
Juan Garibay
2026-09-17 17:17:19 -04:00
parent 3c31747016
commit 4869db30c4
2 changed files with 41 additions and 7 deletions
+15 -4
View File
@@ -169,9 +169,20 @@ repo_ships_interpreter() {
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1
[[ -n "$top" && "$real" == "$top/"* ]] || return 1
git ls-files --error-unmatch -- "${real#"$top"/}" >/dev/null 2>&1
# `:(icase)` because git matches index pathspecs case-sensitively even where
# core.ignorecase is set, while the filesystem underneath does not. On macOS's
# APFS -- the platform this hook most often runs on -- a committed
# `.venv/bin/Python` is what `$venv/bin/python` opens and executes, but a
# case-sensitive query for the lowercase name finds nothing in the index and the
# guard waves it through. Measured: that spelling ran the planted binary twice.
git ls-files --error-unmatch -- ":(icase)${real#"$top"/}" >/dev/null 2>&1
}
# `-I` isolates the probe: without it Python puts the working directory first on
# sys.path, so a repository that commits a `pytest.py` in its root gets that file
# imported -- and executed -- by a check whose only job is to answer whether pytest
# exists. Measured: a committed pytest.py ran during the probe. Isolation does not
# hide a real pytest, which lives in the interpreter's own site-packages.
resolve_pytest() {
# `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is
# still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave
@@ -206,20 +217,20 @@ resolve_pytest() {
log " this hook runs on every push in every repository."
continue
fi
if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then
if "$venv/bin/python" -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=("$venv/bin/python" -m pytest)
return 0
fi
fi
done
if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then
if uv run --no-sync python -c "import pytest" >/dev/null 2>&1; then
if uv run --no-sync python -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=(uv run --no-sync pytest)
return 0
fi
fi
if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then
if poetry run python -c "import pytest" >/dev/null 2>&1; then
if poetry run python -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=(poetry run pytest)
return 0
fi