fix(hermit): stop the git shim fork-bombing across sibling checkouts

`bin/git` resolves the system git by walking PATH with its own directory
removed. Removing only its own directory is not enough. The agent guide
mandates detached worktrees as the sanctioned isolation for parallel agents, so
two checkouts of this repo routinely sit on one PATH — and each shim then
stripped its own `bin/` and resolved `git` to the *other* checkout's identical
shim, which resolved back.

That is mutual recursion with no base case. Observed in practice: 5088 shim
processes plus 2544 `awk` children, 85% of a 10666-process limit, after which
every fork() on the machine failed with "Resource temporarily unavailable" and
no build could start. Nothing in the output named git as the cause.

Two changes, because one is the fix and the other is the blast radius:

- PATH entries whose `git` carries the `hermit-git-shim-v1` marker are skipped,
  not just our own directory, so resolution reaches a real system git in one
  hop. The marker is deliberately repo-neutral — this shim and its CoCO
  descendant are the same script, and a machine carrying both must have each
  recognise the other.
- `HERMIT_GIT_SHIM_ACTIVE` turns any residual loop into one loud error with the
  offending PATH, instead of a machine-wide outage. It is unset before the final
  exec, because hooks legitimately shell out to git and would otherwise fail as
  though they were the loop.

`scripts/test-git-shim-recursion.sh` covers all four properties, including that
no shim processes survive the sibling-on-PATH case.

Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
Josh Belke 2026-08-19 13:19:45 -04:00
commit b3ce97b603
2 changed files with 180 additions and 0 deletions

27
bin/git
View file

@ -3,6 +3,25 @@ set -euo pipefail
readonly script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly minimum_version="2.46.0"
# Sentinel identifying any copy of this shim, including a sibling checkout's.
# Deliberately repo-neutral: this shim and its CoCO descendant are the same
# script, and a machine carrying both must have each recognise the other.
readonly shim_marker="hermit-git-shim-v1"
# Re-entry guard. Skipping only ${script_dir} below is not enough on its own:
# two checkouts of this repo on one PATH each strip their own bin/ and resolve
# git to the *other* shim, which resolves back — mutual recursion that forks
# until the process table is exhausted and every fork() on the machine fails.
# The marker scan below prevents that; this guard makes any residual loop a
# single loud error instead of a machine-wide outage. It is unset before the
# final exec so real git (and hooks that shell out to git) never see it.
if [[ -n "${HERMIT_GIT_SHIM_ACTIVE:-}" ]]; then
echo "error: the Hermit git shim re-entered itself resolving 'git'." >&2
echo " PATH still contains another copy of this shim; no system Git was reached." >&2
echo " PATH=${PATH}" >&2
exit 1
fi
export HERMIT_GIT_SHIM_ACTIVE=1
# Git is Hermit's own transport and therefore cannot safely be a Hermit package:
# doing so recursively invokes Hermit while it holds its package lock. Keep this
@ -12,6 +31,11 @@ path_without_hermit=""
IFS=: read -r -a path_entries <<<"${PATH}"
for entry in "${path_entries[@]}"; do
[[ "${entry}" == "${script_dir}" ]] && continue
# Drop sibling copies of this shim (other worktrees/checkouts on PATH), not
# just our own directory — see the re-entry guard above for why.
if [[ -f "${entry}/git" ]] && grep -qF "${shim_marker}" "${entry}/git" 2>/dev/null; then
continue
fi
path_without_hermit="${path_without_hermit:+${path_without_hermit}:}${entry}"
done
@ -44,4 +68,7 @@ if ! version_at_least "${installed_version}" "${minimum_version}"; then
exit 1
fi
# Real git must not inherit the guard: git hooks legitimately shell out to git,
# and a leaked guard would fail them as though they were the recursion.
unset HERMIT_GIT_SHIM_ACTIVE
exec "${system_git}" "$@"

View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Regression tests for the tracked Hermit git shim at bin/git.
#
# bin/git resolves the *system* git by walking PATH with its own directory
# removed. Removing only its own directory is not enough: the repo agent guide mandates
# detached worktrees as the sanctioned isolation for parallel agents, so two
# checkouts of this repo routinely sit on one PATH. Each shim then stripped its
# own bin/ and resolved `git` to the *other* checkout's identical shim, which
# resolved back — mutual recursion that forks until the process table is
# exhausted. Observed in practice: 5088 shim processes plus 2544 `awk` children,
# 85% of a 10666-process limit, after which every fork() on the machine failed
# with "Resource temporarily unavailable" and no build could start.
#
# The contract:
# - a sibling copy of the shim on PATH is skipped, not execed, so resolution
# reaches a real system git in one hop;
# - re-entry is a single loud error, never an unbounded fork;
# - the re-entry guard never leaks into the real git, because hooks
# legitimately shell out to git and would fail as though they were the loop.
#
# Every shim invocation here is deadline-bounded: a regression must fail this
# test, not take down the machine running it.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
shim="$repo_root/bin/git"
tmp="$(mktemp -d)"
# The recursion orphans its children, so reap by the unique tmp path rather
# than by pid — killing the direct child alone would leave the chain running.
cleanup() {
pkill -9 -f "$tmp" 2>/dev/null || true
rm -rf "$tmp"
}
trap cleanup EXIT
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
pass() {
printf 'ok: %s\n' "$1"
}
if [[ ! -x "$shim" ]]; then
fail "bin/git is missing or not executable"
exit 1
fi
# Run a command with a hard deadline. Returns 124 on timeout, having reaped the
# whole subtree under $tmp so a recursion regression cannot outlive the check.
run_bounded() {
local deadline="$1" outfile="$2"
shift 2
( "$@" >"$outfile" 2>&1 ) &
local pid=$! waited=0
while kill -0 "$pid" 2>/dev/null; do
if ((waited >= deadline)); then
pkill -9 -f "$tmp" 2>/dev/null || true
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
return 124
fi
sleep 1
waited=$((waited + 1))
done
wait "$pid"
}
# A stand-in system git: new enough to clear the shim's version floor, and it
# reports whether the re-entry guard leaked into it.
mkdir -p "$tmp/system"
cat >"$tmp/system/git" <<'FAKEGIT'
#!/usr/bin/env bash
if [[ "${1:-}" == "--version" ]]; then
echo "git version 99.0.0"
else
echo "guard=${HERMIT_GIT_SHIM_ACTIVE:-unset}"
fi
FAKEGIT
chmod +x "$tmp/system/git"
# Two checkouts of this repo on one PATH — the configuration that fork-bombed.
mkdir -p "$tmp/wtA/bin" "$tmp/wtB/bin"
cp "$shim" "$tmp/wtA/bin/git"
cp "$shim" "$tmp/wtB/bin/git"
chmod +x "$tmp/wtA/bin/git" "$tmp/wtB/bin/git"
# The stand-in system git must precede the real one, but /usr/bin and /bin stay
# on PATH: the shim itself calls dirname and awk, and dropping them would test a
# broken environment rather than the resolution order under test.
sibling_path="$tmp/wtA/bin:$tmp/wtB/bin:$tmp/system:/usr/bin:/bin"
# 1. Two shims on one PATH must resolve through to the system git, not recurse.
if run_bounded 20 "$tmp/out1" env PATH="$sibling_path" "$tmp/wtA/bin/git" --version; then
if [[ "$(cat "$tmp/out1")" == "git version 99.0.0" ]]; then
pass "sibling shim on PATH is skipped; system git reached"
else
fail "expected 'git version 99.0.0', got: $(cat "$tmp/out1")"
fi
else
status=$?
if ((status == 124)); then
fail "shim did not terminate with a sibling copy on PATH (recursion regression)"
else
fail "shim exited $status with a sibling copy on PATH: $(cat "$tmp/out1")"
fi
fi
# 2. The recursion must never be unbounded: assert the subtree stayed small.
# A regression spawns thousands here; a correct shim spawns a handful.
# Pass the path through the environment, not argv: with -v the pattern appears
# in awk's own command line and the check matches itself.
spawned="$(ps -axo args= |
shim_glob="$tmp/wt" awk 'index($0, ENVIRON["shim_glob"]){n++} END{print n+0}')"
if ((spawned == 0)); then
pass "no shim processes left running"
else
fail "$spawned shim process(es) survived the resolution"
fi
# 3. Re-entry is a single loud error rather than a fork.
if run_bounded 20 "$tmp/out3" env PATH="$sibling_path" HERMIT_GIT_SHIM_ACTIVE=1 \
"$tmp/wtA/bin/git" --version; then
fail "re-entry guard did not trip when HERMIT_GIT_SHIM_ACTIVE was set"
else
status=$?
if ((status == 124)); then
fail "re-entry guard hung instead of failing fast"
elif ((status == 1)) && grep -q "re-entered itself" "$tmp/out3"; then
pass "re-entry guard fails fast with a diagnostic"
else
fail "re-entry guard exited $status: $(cat "$tmp/out3")"
fi
fi
# 4. The guard must not leak into the real git — hooks shell out to git and
# would otherwise fail as though they were the recursion.
if run_bounded 20 "$tmp/out4" env PATH="$sibling_path" "$tmp/wtA/bin/git" rev-parse; then
if [[ "$(cat "$tmp/out4")" == "guard=unset" ]]; then
pass "re-entry guard is cleared before exec'ing the system git"
else
fail "guard leaked into system git: $(cat "$tmp/out4")"
fi
else
fail "shim failed invoking the system git: $(cat "$tmp/out4")"
fi
if ((failures > 0)); then
printf '\n%d check(s) failed\n' "$failures" >&2
exit 1
fi
printf '\nall git shim recursion checks passed\n'