diff --git a/changelog.d/2026-08-03-tests-git-env-scrub.md b/changelog.d/2026-08-03-tests-git-env-scrub.md new file mode 100644 index 0000000..fb3006e --- /dev/null +++ b/changelog.d/2026-08-03-tests-git-env-scrub.md @@ -0,0 +1,14 @@ +### Changed + +- `tests/__init__.py` scrubs inherited per-repo git environment pointers + (`GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_PREFIX`, + `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, + `GIT_COMMON_DIR`) at package import, so any runner — not only the sanitized + pre-push hook — is safe from the incident where a hook-inherited absolute + `GIT_DIR` made temp-directory git calls inside the suite mutate the real + repository config. `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` are deliberately + untouched (temp-repo commits rely on machine identity); the `scripts/` root + is deliberately untouched (namespace package, and empirically unexposed — + 327/327 under a poisoned `GIT_DIR` with zero config writes). Regression + tests pin both the in-process absence and a freshly-poisoned child-import + scrub. Test infrastructure only — no version bump. diff --git a/tests/__init__.py b/tests/__init__.py index 855cb42..e7e2ad8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,30 @@ # Marketplace-level test suite (cross-cutting checks that span both plugins). # Per-plugin tests live under plugins//backend/tests/ or # plugins//mcp-server/tests/. + +import os + +# Tests in this package spawn git in temporary directories. When the suite is +# run from a git hook (or any process where git has exported its environment), +# an inherited absolute GIT_DIR makes those temp-dir git calls target the REAL +# repository — observed 2026-08-03: a pre-push run set core.bare=true and a +# fixture user identity in the live .git/config (ledger +# plugin.tests.inherit.git.hook.env.mutate.real.repo). Scrub the per-repo +# pointers at package import so every runner is safe, not just the hook (the +# .githooks/pre-push wrapper independently sanitizes its suite subshells). +# GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM are deliberately left alone: tests that +# commit in temp repositories rely on the machine's global identity. A test +# that deliberately exercises poisoned-git-env behavior must set its variables +# AFTER this package import (e.g., in the test body or a subprocess env), as +# the scrub runs once at import time. +for _var in ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_PREFIX", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", +): + os.environ.pop(_var, None) +del _var diff --git a/tests/test_git_env_hardening.py b/tests/test_git_env_hardening.py new file mode 100644 index 0000000..4f771be --- /dev/null +++ b/tests/test_git_env_hardening.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Regression test for the tests-package git-env scrub (see tests/__init__.py). + +Inherited per-repo git pointers (an absolute GIT_DIR exported by a git hook) +made temp-dir git calls in this suite mutate the real repository config. +The package __init__ scrubs them at import; this test pins that property. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest +from pathlib import Path + +SCRUBBED = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_PREFIX", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class GitEnvHardeningTests(unittest.TestCase): + def test_scrubbed_variables_are_absent_after_package_import(self) -> None: + # By the time any test in this package runs, tests/__init__.py has + # imported and the pointers must be gone regardless of the runner. + for var in SCRUBBED: + self.assertNotIn(var, os.environ, var) + + def test_import_scrubs_a_freshly_poisoned_environment(self) -> None: + # Run a child interpreter with every pointer poisoned; importing the + # tests package must remove them all. + env = dict(os.environ) + for var in SCRUBBED: + env[var] = "/nonexistent/poison" + code = ( + "import os, sys; sys.path.insert(0, sys.argv[1]); import tests; " + "leaked = [v for v in sys.argv[2:] if v in os.environ]; " + "print(','.join(leaked) or 'CLEAN')" + ) + res = subprocess.run( + [sys.executable, "-c", code, str(REPO_ROOT), *SCRUBBED], + capture_output=True, + text=True, + env=env, + check=False, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(res.stdout.strip(), "CLEAN") + + +if __name__ == "__main__": + unittest.main()