diff --git a/tests/conftest.py b/tests/conftest.py index 22590496..2772bbdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,68 @@ thirdparty_dir = os.path.join(os.path.dirname(__file__), "..", "thirdparty") if thirdparty_dir not in sys.path: sys.path.insert(0, thirdparty_dir) +_SANDBOX_PROJECT_ROOT: Path | None = None + +_SANDBOX_ALLOWLIST_PATH_PARTS: tuple[str, ...] = ( + ".pytest_cache", + "__pycache__", + ".coverage", + ".slop_cache", + ".ruff_cache", +) + + +def _is_sandbox_path_allowed(resolved: Path, original: str) -> bool: + if _SANDBOX_PROJECT_ROOT is None: + return True + parts = set(resolved.parts) + if any(allowed in parts for allowed in _SANDBOX_ALLOWLIST_PATH_PARTS): + return True + orig_lower = original.lower() + if orig_lower.startswith("\\\\.\\") or orig_lower.startswith("//./"): + return True + if orig_lower.startswith("/dev/"): + return True + try: + resolved.relative_to(_SANDBOX_PROJECT_ROOT / "tests") + return True + except ValueError: + pass + return False + + +def _sandbox_audit_hook(event: str, args: tuple[object, ...]) -> None: + """ + sys.addaudithook target. Blocks writes outside ./tests/ + cache dirs. + Reads pass through. Per FR1 of test_sandbox_hardening_20260619 spec. + [C: tests/conftest.py:pytest_configure, tests/test_test_sandbox.py] + """ + if event != "open": + return + if not args: + return + path_obj = args[0] + mode = args[1] if len(args) > 1 else "" + if not isinstance(mode, str): + return + if not any(m in mode for m in ("w", "a", "x", "+")): + return + try: + path_str = os.fspath(path_obj) + except (TypeError, ValueError): + return + try: + resolved = Path(path_str).resolve() + except (OSError, ValueError, RuntimeError): + return + if _is_sandbox_path_allowed(resolved, path_str): + return + raise RuntimeError( + f"TEST_SANDBOX_VIOLATION: attempted to write to {resolved} " + f"(outside /tests/). Use tmp_path or fixture-provided paths. " + f"See conductor/code_styleguides/test_sandbox.md for guidance." + ) + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if project_root not in sys.path: sys.path.insert(0, project_root) @@ -118,11 +180,16 @@ _pytest_finished_event: threading.Event = threading.Event() def pytest_configure(config: object) -> None: """ - Pytest session-start hook. Runs required-dependency check before any - test is collected so the user sees a clear, actionable error if the - test environment is incomplete. - [C: tests/test_required_test_dependencies.py:test_check_succeeds_when_deps_present, tests/test_required_test_dependencies.py:test_check_raises_on_missing_sentence_transformers] + Pytest session-start hook. Installs the runtime sandbox audit hook + (FR1 of test_sandbox_hardening_20260619) BEFORE any test module + imports so misbehaving tests cannot write outside ./tests/. Then + runs the required-dependency check so the user sees a clear, + actionable error if the test environment is incomplete. + [C: tests/test_required_test_dependencies.py:test_check_succeeds_when_deps_present, tests/test_required_test_dependencies.py:test_check_raises_on_missing_sentence_transformers, tests/test_test_sandbox.py] """ + global _SANDBOX_PROJECT_ROOT + _SANDBOX_PROJECT_ROOT = Path(__file__).resolve().parent.parent + sys.addaudithook(_sandbox_audit_hook) _check_required_test_dependencies() @@ -256,6 +323,17 @@ class VerificationLogger: f.write(f"{status} {self.test_name} ({result_msg})\n\n") print(f"[FINAL] {self.test_name}: {status} - {result_msg}") +@pytest.fixture(autouse=True) +def _enforce_test_sandbox() -> Generator[None, None, None]: + """ + Default-on runtime guard (FR1 of test_sandbox_hardening_20260619 spec). + The actual sys.addaudithook is installed in pytest_configure (session- + scoped, BEFORE any test module imports). This autouse fixture exists + as a marker so the contract is visible in fixture introspection. + [C: tests/conftest.py:pytest_configure, tests/test_test_sandbox.py] + """ + yield + @pytest.fixture(autouse=True) def isolate_workspace(tmp_path_factory, monkeypatch) -> Generator[None, None, None]: """ diff --git a/tests/test_test_sandbox.py b/tests/test_test_sandbox.py index 21577f5b..fe8ea5cf 100644 --- a/tests/test_test_sandbox.py +++ b/tests/test_test_sandbox.py @@ -1,10 +1,15 @@ -"""Tests for scripts/audit_test_sandbox_violations.py (Phase 2, FR4).""" +"""Tests for scripts/audit_test_sandbox_violations.py (Phase 2, FR4) and +the Python audit guard in tests/conftest.py (Phase 3, FR1). +""" from __future__ import annotations +import os import re import subprocess import sys from pathlib import Path +import pytest + def test_audit_runs_without_error() -> None: """The audit script runs and exits cleanly.""" @@ -84,4 +89,62 @@ def test_audit_subprocess_bad_dir_exits_one() -> None: assert result.returncode == 1, f"Expected exit 1, got {result.returncode}" finally: bad.unlink(missing_ok=True) - tmp_dir.rmdir() \ No newline at end of file + tmp_dir.rmdir() + + +def test_sandbox_blocks_writes_outside_tests_dir() -> None: + """A write to /manual_slop.toml raises TEST_SANDBOX_VIOLATION.""" + bad_path = Path(__file__).resolve().parent.parent / "manual_slop.toml" + if bad_path.exists(): + original = bad_path.read_bytes() + existed = True + else: + existed = False + original = b"" + try: + with pytest.raises(RuntimeError, match="TEST_SANDBOX_VIOLATION"): + bad_path.write_text("corrupt", encoding="utf-8") + finally: + if existed: + bad_path.write_bytes(original) + elif bad_path.exists(): + bad_path.unlink() + + +def test_sandbox_allows_writes_inside_tests_dir(tmp_path) -> None: + """A write to tmp_path (which lives under tests/artifacts/_pytest_tmp) succeeds.""" + target = tmp_path / "foo.txt" + target.write_text("ok", encoding="utf-8") + assert target.read_text(encoding="utf-8") == "ok" + + +def test_sandbox_allows_writes_inside_tests_artifacts() -> None: + """A write to tests/artifacts/_sandbox_test_allows/foo.txt succeeds.""" + p = Path("tests/artifacts/_sandbox_test_allows/foo.txt") + p.parent.mkdir(parents=True, exist_ok=True) + try: + p.write_text("ok", encoding="utf-8") + assert p.read_text(encoding="utf-8") == "ok" + finally: + p.unlink(missing_ok=True) + if p.parent.exists(): + p.parent.rmdir() + + +def test_sandbox_does_not_block_reads() -> None: + """A read of /pyproject.toml succeeds (reads are always allowed).""" + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + content = pyproject.read_text(encoding="utf-8") + assert "[tool.pytest.ini_options]" in content + + +def test_sandbox_allows_pytest_cache_write() -> None: + """Writes under .pytest_cache are allowed (pytest internal cache).""" + cache_root = Path(__file__).resolve().parent.parent / ".pytest_cache" + probe = cache_root / "_sandbox_probe.txt" + cache_root.mkdir(parents=True, exist_ok=True) + try: + probe.write_text("ok", encoding="utf-8") + assert probe.read_text(encoding="utf-8") == "ok" + finally: + probe.unlink(missing_ok=True) \ No newline at end of file