Files
ECC/scripts/codex-git-hooks/pre-push
T
Juan Garibay 9cdc40e6d1 fix(hooks): do not run a virtualenv interpreter the repository ships
This branch taught the hook to run `.venv/bin/python`, and that is a binary the
repository can supply. On main the Python arm only ever ran `pytest` from PATH --
the developer's own -- and on a machine without one it ran nothing at all, which
is exactly the machine this branch was written for. So the exposure is new, and
it arrived with the fix.

The hook is installed globally through core.hooksPath. Cloning a hostile
repository, committing nothing, and pushing it to your own fork is enough: the
pre-push hook finds the committed `.venv/bin/python`, runs it once to probe for
pytest and again to run the suite. Reproduced -- the planted executable logged
two invocations under the previous commit and none under this one.

A virtualenv is never committed. It is platform-specific binaries and every
Python project gitignores it, so `git ls-files --error-unmatch` separates the
two cases exactly: a developer's own venv is untracked and still resolves, a
tracked one is skipped with the reason printed. An absolute $VIRTUAL_ENV outside
the worktree reads as untracked, as it should.

Not addressed here, and worth a maintainer's view: `uv run` and `poetry run`
resolve from the repository's own lockfile, so they carry the same shape of
trust in a form this check cannot see. They are gated behind a lockfile being
present, and changing their semantics is a larger decision than this fix.
2026-09-17 16:44:33 -04:00

264 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ECC Codex Git Hook: pre-push
# Runs a lightweight verification flow before pushes.
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then
exit 0
fi
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
# Skip checks for branch deletion pushes (e.g., git push origin --delete <branch>).
# The pre-push hook receives lines on stdin: <local ref> <local sha> <remote ref> <remote sha>.
# For deletions, the local sha is the zero OID.
is_delete_only=true
while read -r _local_ref local_sha _remote_ref _remote_sha; do
if [[ "$local_sha" != "0000000000000000000000000000000000000000" ]]; then
is_delete_only=false
break
fi
done
if [[ "$is_delete_only" == "true" ]]; then
exit 0
fi
ran_any_check=0
log() {
printf '[ECC pre-push] %s\n' "$*"
}
fail() {
printf '[ECC pre-push] FAILED: %s\n' "$*" >&2
exit 1
}
detect_pm() {
if [[ -f "pnpm-lock.yaml" ]]; then
echo "pnpm"
elif [[ -f "bun.lockb" ]]; then
echo "bun"
elif [[ -f "yarn.lock" ]]; then
echo "yarn"
elif [[ -f "package-lock.json" ]]; then
echo "npm"
else
echo "npm"
fi
}
has_node_script() {
local script_name="$1"
node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)' "$script_name" >/dev/null 2>&1
}
run_pnpm() {
if command -v corepack >/dev/null 2>&1; then
# Corepack may download the pinned pnpm version on a cache miss. Set
# COREPACK_ENABLE_NETWORK=0 to make an offline cache miss fail immediately.
corepack pnpm "$@"
elif command -v pnpm >/dev/null 2>&1; then
pnpm "$@"
else
fail "pnpm could not be resolved from PATH or Corepack"
fi
}
run_node_script() {
local pm="$1"
local script_name="$2"
case "$pm" in
pnpm) run_pnpm run "$script_name" ;;
bun) bun run "$script_name" ;;
yarn) yarn "$script_name" ;;
npm) npm run "$script_name" ;;
*) npm run "$script_name" ;;
esac
}
if [[ -f "package.json" ]]; then
pm="$(detect_pm)"
log "Node project detected (package manager: $pm)"
for script_name in lint typecheck test build; do
if has_node_script "$script_name"; then
ran_any_check=1
log "Running: $script_name"
run_node_script "$pm" "$script_name" || fail "$script_name failed"
else
log "Skipping missing script: $script_name"
fi
done
if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then
ran_any_check=1
log "Running dependency audit (ECC_PREPUSH_AUDIT=1)"
case "$pm" in
pnpm) run_pnpm audit --prod || fail "pnpm audit failed" ;;
bun) bun audit || fail "bun audit failed" ;;
yarn) yarn npm audit --recursive || fail "yarn audit failed" ;;
npm) npm audit --omit=dev || fail "npm audit failed" ;;
*) npm audit --omit=dev || fail "npm audit failed" ;;
esac
fi
fi
if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
ran_any_check=1
log "Go project detected. Running: go test ./..."
go test ./... || fail "go test failed"
fi
# Resolve how this project runs pytest, into PYTEST_CMD as an argv array.
#
# Looking only for `pytest` on PATH meant the hook skipped every project that keeps
# its tools in a virtualenv -- which is most of them -- and reported "pytest is not
# installed" while sitting next to a .venv with pytest in it. A gate that silently
# declines to gate is worse than no gate, because the skip line reads like a pass.
#
# An array rather than one string, because a virtualenv path may contain spaces:
# a scalar command splits `/home/me/my env/bin/python` into two paths that do not
# exist, and the hook then rejects the push for a reason that has nothing to do
# with the code being pushed.
#
# Echoes the command it will run, so the reason for a skip is always visible.
PYTEST_CMD=()
# Does this command actually run pytest? Accepting `--version` is not evidence --
# plenty of programs take it and exit 0 -- so the output has to name pytest. The
# version is captured rather than piped: under `set -o pipefail` a `| grep -q` can
# report the SIGPIPE of the program it just matched.
#
# Only ever called on a command this script composed itself. Probing an arbitrary
# operator-supplied command is not safe: a wrapper that ignores `--version` and
# execs pytest runs the entire suite during the probe, and is then rejected for
# not having printed a version.
is_pytest() {
local version
version="$("$@" --version 2>&1)" || return 1
grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version"
}
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
# alike, where the first used to fall through to discovery and the second failed
# the push. Falling through is the wrong half of that pair -- an override that
# evaluated empty (a command substitution that found nothing, say) would silently
# run a different runner than the operator asked for, which is the substitution
# this resolver refuses to make anywhere else.
#
# Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash`
# is 3.2, where it is a syntax error rather than a false. This hook ships to
# whatever `env bash` finds.
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
# Taken as given. This is a deliberate override, and the hook cannot inspect it
# without running it -- a wrapper script may ignore `--version` and run the
# suite, so probing costs a duplicate test run and then blocks the push anyway.
# Pointing this at something that is not pytest turns the gate off, and that is
# the operator's call to make, not a misconfiguration for the hook to second
# guess. Word-split, so the command names something on PATH or an interpreter
# whose path has no spaces; a venv with spaces is found by the loop below.
read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true
[[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command"
return 0
fi
local venv
for venv in "${VIRTUAL_ENV:-}" .venv venv env; do
if [[ -n "$venv" && -x "$venv/bin/python" ]]; then
# A virtualenv is never committed -- it is platform-specific binaries, and
# every Python project gitignores it. One that IS tracked is the repository
# handing this hook an executable and asking it to run. The hook is installed
# globally, so cloning a hostile repository and pushing it to your own fork
# would be enough, and on a machine with no pytest on PATH this arm is the
# only thing that would run at all. Skipping costs nothing legitimate,
# because a developer's own venv is untracked -- and it says why rather than
# going quiet about it.
if git ls-files --error-unmatch -- "$venv/bin/python" >/dev/null 2>&1; then
log "Ignoring $venv/bin/python: it is tracked in this repository."
log " A committed virtualenv is an executable the repository controls, and"
log " this hook runs on every push in every repository."
continue
fi
if "$venv/bin/python" -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
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
PYTEST_CMD=(poetry run pytest)
return 0
fi
fi
# `command -v` proves only that a file of that name exists on PATH. This one the
# script composed itself, so confirming it costs a harmless `pytest --version`.
if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then
PYTEST_CMD=(pytest)
return 0
fi
PYTEST_CMD=()
return 1
}
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
if resolve_pytest; then
ran_any_check=1
log "Python project detected. Running: ${PYTEST_CMD[*]} -q"
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
# resolve_pytest deliberately does not verify the override is pytest, because
# probing it can run the operator's suite. What this gate can honestly do
# about a stale override is refuse to be quiet about it: a bypass announced
# on every push is not the silent gate this resolver exists to prevent.
log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does"
log " not check that it is pytest. Unset it to gate on the real suite."
fi
pytest_status=0
"${PYTEST_CMD[@]}" -q || pytest_status=$?
case "$pytest_status" in
0) ;;
# pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A
# pyproject.toml that only configures ruff or black is still a Python project
# by this hook's test, and blocking those pushes would make the gate something
# people switch off. Never silent, though: a bad rootdir, testpaths or a
# conftest that fails to import also collects nothing, and swallowing that is
# the same skip-reads-like-a-pass hole this resolver exists to close.
5)
log "pytest collected no tests (exit 5). Not gating this push."
log " If this repository is supposed to have tests, that is the bug:"
log " check rootdir, testpaths, and conftest.py import errors."
;;
# The code is in the message because 1 (tests failed) and 4 (usage error)
# need different responses, and "pytest failed" alone cannot tell them apart.
*) fail "pytest failed (exit $pytest_status)" ;;
esac
else
log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv,"
log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it."
fi
fi
if [[ "$ran_any_check" -eq 0 ]]; then
log "No supported checks found in this repository. Skipping."
else
log "Verification checks passed."
fi
exit 0