Private
Public Access
feat(tests): add FR1 Python runtime sandbox via sys.addaudithook
This commit is contained in:
+82
-4
@@ -21,6 +21,68 @@ thirdparty_dir = os.path.join(os.path.dirname(__file__), "..", "thirdparty")
|
|||||||
if thirdparty_dir not in sys.path:
|
if thirdparty_dir not in sys.path:
|
||||||
sys.path.insert(0, thirdparty_dir)
|
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 <project_root>/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__), ".."))
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
if project_root not in sys.path:
|
if project_root not in sys.path:
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
@@ -118,11 +180,16 @@ _pytest_finished_event: threading.Event = threading.Event()
|
|||||||
|
|
||||||
def pytest_configure(config: object) -> None:
|
def pytest_configure(config: object) -> None:
|
||||||
"""
|
"""
|
||||||
Pytest session-start hook. Runs required-dependency check before any
|
Pytest session-start hook. Installs the runtime sandbox audit hook
|
||||||
test is collected so the user sees a clear, actionable error if the
|
(FR1 of test_sandbox_hardening_20260619) BEFORE any test module
|
||||||
test environment is incomplete.
|
imports so misbehaving tests cannot write outside ./tests/. Then
|
||||||
[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]
|
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()
|
_check_required_test_dependencies()
|
||||||
|
|
||||||
|
|
||||||
@@ -256,6 +323,17 @@ class VerificationLogger:
|
|||||||
f.write(f"{status} {self.test_name} ({result_msg})\n\n")
|
f.write(f"{status} {self.test_name} ({result_msg})\n\n")
|
||||||
print(f"[FINAL] {self.test_name}: {status} - {result_msg}")
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def isolate_workspace(tmp_path_factory, monkeypatch) -> Generator[None, None, None]:
|
def isolate_workspace(tmp_path_factory, monkeypatch) -> Generator[None, None, None]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_audit_runs_without_error() -> None:
|
def test_audit_runs_without_error() -> None:
|
||||||
"""The audit script runs and exits cleanly."""
|
"""The audit script runs and exits cleanly."""
|
||||||
@@ -85,3 +90,61 @@ def test_audit_subprocess_bad_dir_exits_one() -> None:
|
|||||||
finally:
|
finally:
|
||||||
bad.unlink(missing_ok=True)
|
bad.unlink(missing_ok=True)
|
||||||
tmp_dir.rmdir()
|
tmp_dir.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_blocks_writes_outside_tests_dir() -> None:
|
||||||
|
"""A write to <project_root>/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 <project_root>/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)
|
||||||
Reference in New Issue
Block a user