44 KiB
Track Implementation Plan: Test Sandbox Hardening (2026-06-19)
For Tier 3 workers: This plan is executed task-by-task per
conductor/workflow.md. Each task has WHERE / WHAT / HOW / SAFETY / COMMIT / GIT NOTE fields. Use the spec atconductor/tracks/test_sandbox_hardening_20260619/spec.mdas the authoritative reference for FR/NFR/VC details.
Goal: Make any pytest or run_tests_batched.py invocation provably incapable of writing files outside ./tests/ at the Python layer (default-on) and at the OS layer (opt-in via scripts/run_tests_sandboxed.ps1), by replacing the silent SLOP_CONFIG env-var fallback with an explicit --config CLI flag and adding a runtime file-I/O guard.
Architecture: 5-part fix — (1) src/paths.py removes the env-var fallback; (2) sloppy.py + conftest.py parse --config and call paths.set_config_override(); (3) sys.addaudithook blocks writes outside ./tests/; (4) pytest's --basetemp + conftest's isolate_workspace migrated under ./tests/; (5) opt-in Windows restricted-token wrapper. Tests are TDD (red → green → commit).
Tech Stack: Python 3.11+, sys.addaudithook, pytest 9.0+, PowerShell 7+, existing tomli_w, tomllib.
Reference files:
- Spec:
conductor/tracks/test_sandbox_hardening_20260619/spec.md - Existing pattern:
scripts/audit_no_temp_writes.py(audit script),scripts/tier2/run_tier2_sandboxed.ps1(PowerShell wrapper),scripts/check_test_toml_paths.py(existing static audit).
Phase 1: Investigation + Baseline
Focus: Capture current pass count + audit src/ for get_config_path() callers so FR2 changes are transparent.
-
Task 1.1: Capture baseline pass count.
- WHERE: None (read-only audit).
- WHAT: Run the full test suite and record results.
- HOW:
uv run python scripts/run_tests_batched.py --tiers 1,2,3,4,5,6,7,8,9,10,11 > tests/artifacts/_baseline_pre_sandbox.txt 2>&1 - SAFETY: Capture pass count + skip count + duration to
tests/artifacts/_baseline_pre_sandbox_summary.txt. Do NOT modify any source file. - COMMIT: None (audit-only).
- GIT NOTE: None.
-
Task 1.2: Audit
src/forget_config_path()callers.- WHERE:
src/(grep audit). - WHAT: Find every call site of
paths.get_config_path()andmodels._load_config_from_disk()/models._save_config_to_disk(). The FR2 change (removing env-var fallback) must be transparent to all of them. - HOW:
grep -rn "get_config_path\|_load_config_from_disk\|_save_config_to_disk" src/ > tests/artifacts/_get_config_path_callers.txt cat tests/artifacts/_get_config_path_callers.txt | wc -l # record count - SAFETY: Expected ~10-20 call sites. All must be transparent because FR2's default (
<project_root>/config.toml) matches the current silent fallback behavior. - COMMIT: None.
- GIT NOTE: None.
- WHERE:
-
Task 1.3: Phase 1 verification.
- WHERE: None.
- WHAT: Confirm baseline + audit files exist + no source changes since session start.
- HOW:
ls tests/artifacts/_baseline_pre_sandbox* tests/artifacts/_get_config_path_callers.txt; git status --short | wc -l - SAFETY: Phase 1 is READ-ONLY;
git statusmust show 0 modified source files. - COMMIT: None.
- GIT NOTE: None.
Phase 2: FR4 Static Audit (LOW RISK — ship first)
Focus: Write the static audit script that flags test files with hardcoded paths or tempfile.mkdtemp() without dir=. CI gate (default informational, --strict exits 1).
-
Task 2.1: Write
scripts/audit_test_sandbox_violations.py. [43e50f9]- WHERE: Create
scripts/audit_test_sandbox_violations.py. - WHAT: Mirror
scripts/check_test_toml_paths.pystructure (compiled regexes +find_violations(root_dir)+main()with--strict). - HOW: Patterns:
Plus a
TOML_BASENAMES = r"manual_slop|config|credentials|presets|personas|tool_presets|workspace_profiles|project|manualslop_layout|manualslop_history|manualslop_history" PATTERNS = [ re.compile(rf'Path\(["\'](?:{TOML_BASENAMES})\.toml["\']'), re.compile(rf'Path\(["\'](?:{TOML_BASENAMES})\.ini["\']'), re.compile(rf'open\(["\'](?:{TOML_BASENAMES})\.toml["\'], ["\']w["\']'), re.compile(r'Path\(["\']C:[/\\]+projects'), re.compile(r'Path\(["\']tests/artifacts/'), re.compile(r"tempfile\.mk(dt|st)emp\("), # bare calls without dir= ] EXCLUDE_DIRS = {"artifacts", "logs", "__pycache__"}find_violations(tests_dir)that scanstests/test_*.pyand returnslist[tuple[Path, int, str]]. Plusmain()with--strict(exit 1 on any violation; default exit 0 with report). - SAFETY: Audit is INFORMATIONAL by default (exits 0).
--strictexits 1 only on violations. Perconductor/code_styleguides/audit-script-conventions.md(if exists) oraudit_no_temp_writes.pyprecedent. - COMMIT:
chore(audit): add scripts/audit_test_sandbox_violations.py + tests for FR4 (Phase 2) - GIT NOTE: "Phase 2: static audit script + 3 regression tests for FR4 (hardcoded paths, clean test, tempfile.mkdtemp without dir=). Audit default informational, --strict exits 1."
- WHERE: Create
-
Task 2.2: Write tests 5, 6, 10 in
tests/test_test_sandbox.py. [43e50f9]- WHERE: Create
tests/test_test_sandbox.py. - WHAT: Three tests for the audit script. Imports + test signatures use 1-space indentation per
conductor/workflow.md. - HOW:
import subprocess, sys from pathlib import Path def test_audit_flags_known_bad_pattern() -> None: bad = Path("tests/artifacts/_audit_test_bad.py") bad.parent.mkdir(parents=True, exist_ok=True) bad.write_text('Path("manual_slop.toml").write_text("x")\n', encoding="utf-8") result = subprocess.run([sys.executable, "scripts/audit_test_sandbox_violations.py", "--strict"], capture_output=True, text=True) assert result.returncode == 1, f"Expected exit 1, got {result.returncode}" bad.unlink() def test_audit_passes_clean_test() -> None: good = Path("tests/artifacts/_audit_test_good.py") good.parent.mkdir(parents=True, exist_ok=True) good.write_text("def test_x(tmp_path): tmp_path.joinpath('foo').write_text('x')\n", encoding="utf-8") result = subprocess.run([sys.executable, "scripts/audit_test_sandbox_violations.py", "--strict"], capture_output=True, text=True) assert result.returncode == 0, f"Expected exit 0, got {result.returncode}: {result.stdout}" good.unlink() def test_audit_flags_tempfile_mkdtemp_without_tests_dir() -> None: bad = Path("tests/artifacts/_audit_test_tempfile.py") bad.parent.mkdir(parents=True, exist_ok=True) bad.write_text("import tempfile\ndef test_x(): tempfile.mkdtemp()\n", encoding="utf-8") result = subprocess.run([sys.executable, "scripts/audit_test_sandbox_violations.py", "--strict"], capture_output=True, text=True) assert result.returncode == 1, f"Expected exit 1, got {result.returncode}" bad.unlink() - SAFETY: Tests must clean up their temp files even on failure (use
try/finallyor pytest fixture cleanup). - COMMIT: Same as 2.1 (combined commit).
- GIT NOTE: Same as 2.1.
- WHERE: Create
-
Task 2.3: Run Phase 2 tests to verify. [
43e50f9] (note: not yet run due to user directive to defer pytest invocation until FR1 guard is in place)- WHERE: None.
- WHAT: Run the 3 new tests + manually invoke the audit script with a known-bad fixture file.
- HOW:
uv run python -m pytest tests/test_test_sandbox.py -v -k "audit_" - SAFETY: All 3 must pass. If any fail, debug and fix before committing.
- COMMIT: Same as 2.1.
- GIT NOTE: Same as 2.1.
Phase 3: FR1 Python Guard (HIGH RISK — must be precise)
Focus: Implement sys.addaudithook to block all Python writes outside ./tests/ with RuntimeError("TEST_SANDBOX_VIOLATION").
-
Task 3.1: Write
_enforce_test_sandboxautouse fixture intests/conftest.py. [e733e52]- WHERE: Modify
tests/conftest.py— add new fixture nearisolate_workspaceat line ~258. - WHAT: Install
sys.addaudithookforopen(write modes),os.mkdir,os.makedirs,shutil.rmtree,tempfile.mkdtemp,tempfile.mkstemp. Allowlist = anything under<project_root>/tests/. Block everything else. - HOW: (Insert before the existing
isolate_workspacefixture):IMPORTANT: The existing_SANDBOX_ALLOWLIST_PREFIXES: tuple[str, ...] = () # initialized in pytest_configure def _sandbox_audit_hook(event: str, args: tuple[object, ...]) -> None: """sys.addaudithook target. Blocks writes outside ./tests/.""" if event == "open": path_obj, mode, *_ = args if not isinstance(path_obj, (str, bytes, os.PathLike)): return if isinstance(mode, str) and not any(m in mode for m in ("w", "a", "x", "+")): return try: resolved = Path(os.fspath(path_obj)).resolve() except (OSError, ValueError): return if not _is_under_tests(resolved): raise RuntimeError( f"TEST_SANDBOX_VIOLATION: attempted to write to {resolved} " f"(outside <project_root>/tests/). Use tmp_path or fixture-provided paths." ) def _is_under_tests(path: Path) -> bool: for prefix in _SANDBOX_ALLOWLIST_PREFIXES: try: path.relative_to(prefix) return True except ValueError: pass return False @pytest.fixture(autouse=True) def _enforce_test_sandbox() -> Generator[None, None, None]: """Default-on runtime guard. Installed in pytest_configure.""" yield # No-op; hook is installed at session start. def pytest_configure(config: object) -> None: global _SANDBOX_ALLOWLIST_PREFIXES project_root = Path(__file__).resolve().parent.parent _SANDBOX_ALLOWLIST_PREFIXES = ( str(project_root / "tests"), str(Path("tests/artifacts/_pytest_tmp").resolve()), str(Path("tests/artifacts/_isolation_workspace").resolve()), ) sys.addaudithook(_sandbox_audit_hook) _check_required_test_dependencies() # existing call def pytest_unconfigure(config: object) -> None: # Note: sys.addaudithook is permanent for the process; no removal API. # The hook stays active until process exit (pytest is the only Python here). passpytest_configureat conftest.py:140 must be MERGED with the new one (don't create two definitions). - SAFETY: The hook ONLY blocks write modes. Reads pass through.
.pytest_cache,__pycache__,.coveragelive under./tests/or project_root — verify with a quick test run before committing. - COMMIT:
feat(tests): add _enforce_test_sandbox autouse fixture for FR1 (Phase 3) - GIT NOTE: "Phase 3: Python sys.addaudithook runtime guard. Blocks writes outside ./tests/ with TEST_SANDBOX_VIOLATION RuntimeError. Reads unaffected. Layer 1 of 4 enforcement stack."
- WHERE: Modify
-
Task 3.2: Write tests 1-4 in
tests/test_test_sandbox.py. [e733e52]- WHERE: Add to existing
tests/test_test_sandbox.py(created in Phase 2). - WHAT: Four tests verifying guard behavior.
- HOW:
def test_sandbox_blocks_writes_outside_tests_dir() -> None: bad_path = Path(__file__).resolve().parent.parent / "manual_slop.toml" with pytest.raises(RuntimeError, match="TEST_SANDBOX_VIOLATION"): bad_path.write_text("corrupt", encoding="utf-8") def test_sandbox_allows_writes_inside_tests_dir(tmp_path) -> None: (tmp_path / "foo.txt").write_text("ok", encoding="utf-8") assert (tmp_path / "foo.txt").read_text(encoding="utf-8") == "ok" def test_sandbox_allows_writes_inside_tests_artifacts() -> None: p = Path("tests/artifacts/_sandbox_test_allows/foo.txt") p.parent.mkdir(parents=True, exist_ok=True) p.write_text("ok", encoding="utf-8") assert p.read_text(encoding="utf-8") == "ok" p.unlink() def test_sandbox_does_not_block_reads() -> None: pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" content = pyproject.read_text(encoding="utf-8") assert "[tool.pytest.ini_options]" in content - SAFETY: Test 1 is expected to RAISE; pytest.raises catches it. Tests 2-3 must SUCCEED. Test 4 must SUCCEED (read-only).
- COMMIT: Same as 3.1 (combined).
- GIT NOTE: Same as 3.1.
- WHERE: Add to existing
-
Task 3.3: Run full Tier-1 unit suite to verify no regression. [deferred to Phase 8 verification per user directive to not run pytest until safety mechanism is in place; FR1 static structure verified via AST + isolated hook logic test]
- WHERE: None.
- WHAT: Confirm the guard doesn't break any Tier-1 test that legitimately writes within
./tests/. - HOW:
uv run python -m pytest tests/ --collect-only -q | head -50(just verify collection works). Thenuv run python scripts/run_tests_batched.py --tiers 1 --timeout 120 - SAFETY: Tier-1 may have tests that write to
tmp_path(which now resolves under./tests/artifacts/_pytest_tmp). If any test fails, the guard's allowlist needs expansion. Document and add to allowlist only after careful review (the test should already be usingtmp_path). - COMMIT: Same as 3.1.
- GIT NOTE: Same as 3.1.
Phase 4: FR2 Root-Cause Fix (--config CLI flag — MOST IMPORTANT)
Focus: Replace the silent SLOP_CONFIG env-var fallback in src/paths.py with an explicit set_config_override() module-level setter, called from CLI parsers in sloppy.py and tests/conftest.py. This is THE fix for the user's data-loss pain.
-
Task 4.1: Refactor
src/paths.pyto remove the env-var fallback. [02fef00]- WHERE: Modify
src/paths.py:42-46(theget_config_path()function). - WHAT: Remove
os.environ.get("SLOP_CONFIG", ...)lookup. Add module-level_CONFIG_OVERRIDE: Path | None = Noneandset_config_override(path: Path | None) -> Nonefunction. - HOW:
_CONFIG_OVERRIDE: Path | None = None def set_config_override(path: Path | None) -> None: """Set the active config.toml path. None = use default. CLI flag is the ONLY way to override. No env var fallback. [C: sloppy.py:main, tests/conftest.py:_setup_test_paths]""" global _CONFIG_OVERRIDE _CONFIG_OVERRIDE = path _RESOLVED.clear() def get_config_path() -> Path: """Returns the active config.toml. If override is set, returns it. Otherwise returns the default <project_root>/config.toml. [C: src/app_controller.py:AppController.load_config, src/app_controller.py:AppController.init_state, src/models.py:_load_config_from_disk]""" if _CONFIG_OVERRIDE is not None: return _CONFIG_OVERRIDE root_dir = Path(__file__).resolve().parent.parent return root_dir / "config.toml" - SAFETY: The default behavior (no override) returns the same path as the previous env-var fallback when
SLOP_CONFIGwas unset. This is the SAME path the desktop GUI currently uses. So sloppy.py without--configworks unchanged. - COMMIT:
fix(paths): remove SLOP_CONFIG env-var fallback from get_config_path() (Phase 4, FR2 root-cause) - GIT NOTE: "Phase 4 task 4.1: root-cause fix for data loss. src/paths.py no longer silently falls back to <project_root>/config.toml via SLOP_CONFIG env var. New API: paths.set_config_override(path). Default behavior unchanged when no override is set."
- WHERE: Modify
-
Task 4.2: Remove diagnostic stderr line from
src/models.py:193. [02fef00]- WHERE: Modify
src/models.py:193(in_save_config_to_disk). - WHAT: Delete the
sys.stderr.write(f"[DEBUG] Saving config. Theme: {config.get('theme')}\n"); sys.stderr.flush()line. PerAGENTS.md"No Diagnostic Noise in Production" rule. - HOW: Delete the two lines.
- SAFETY: This is a pure removal of diagnostic noise. No behavior change for normal operation. If any test depends on this stderr output, it should be removed too (check
tests/forcapsysfixtures matching this output). - COMMIT: Same as 4.1 (combined commit "src cleanup for FR2").
- GIT NOTE: Same as 4.1.
- WHERE: Modify
-
Task 4.3: Add
--configargparse tosloppy.py. [02fef00]- WHERE: Modify
sloppy.py— the argparse setup (find the existingArgumentParserblock). - WHAT: Add
--config <path>flag. Callpaths.set_config_override(args.config)BEFORE anysrc/import. - HOW:
parser.add_argument("--config", type=str, default=None, help="Path to config.toml (default: <project_root>/config.toml)") # ... parse args ... if args.config: from src import paths paths.set_config_override(Path(args.config).resolve()) # THEN import the rest: from src.gui_2 import App # existing import below - SAFETY: The
set_config_overridecall must happen BEFOREfrom src.gui_2 import Appbecause that import chain eventually imports paths and may triggerget_config_path(). - COMMIT:
feat(sloppy): add --config CLI flag for config.toml override (Phase 4, FR2) - GIT NOTE: "Phase 4 task 4.3: sloppy.py accepts --config . Sets paths.set_config_override() before any src/ import. Default behavior unchanged."
- WHERE: Modify
-
Task 4.4: Update
tests/conftest.pyto parse--configat module body. [02fef00]- WHERE: Modify
tests/conftest.py— INSERT NEW CODE at the TOP of the file (before the existingimport pytestline, around line 14). - WHAT: Parse
sys.argvfor--configat module body BEFORE anysrc/import. Auto-default totests/artifacts/_isolation_workspace_<RUN_ID>/config_overrides.toml. Also register with pytest viapytest_addoption. - HOW:
IMPORTANT: This block must be inserted BEFORE
# === STAGE 1: Parse --config from sys.argv BEFORE any src/ import === import sys as _sys from pathlib import Path as _Path _RUN_ID = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") _ISOLATION_WORKSPACE = _Path(f"tests/artifacts/_isolation_workspace_{_RUN_ID}") _ISOLATION_WORKSPACE.mkdir(parents=True, exist_ok=True) def _parse_config_arg(argv: list[str]) -> _Path | None: for i, arg in enumerate(argv[1:]): if arg == "--config" and i + 1 < len(argv) - 1: return _Path(argv[i + 2]).resolve() if arg.startswith("--config="): return _Path(arg.split("=", 1)[1]).resolve() return None _config_override_arg = _parse_config_arg(_sys.argv) if _config_override_arg is None: _config_override_arg = _ISOLATION_WORKSPACE / "config_overrides.toml" # Set override BEFORE any src/ import from src import paths as _paths # noqa: E402 _paths.set_config_override(_config_override_arg) # Register --config with pytest so it doesn't warn about unknown flag def pytest_addoption(parser): parser.addoption("--config", action="store", default=None, help="Manual Slop: override config.toml path for tests")from src.app_controller import AppController(line 64) and BEFORE any othersrc/imports. Also DELETE thefrom src.gui_2 import Appline at line ~250 (move it after the new fixture insertion point to keep imports tidy). - SAFETY: The sys.argv parse happens at conftest module import time, BEFORE pytest's argparse. The auto-generated
_config_override_arglives inside./tests/artifacts/, which the Layer 1 guard will allowlist. Tests that explicitly pass--config /some/pathget that override. Tests without--configget the auto-sandbox. - COMMIT:
feat(tests): parse --config CLI flag in conftest.py module body (Phase 4, FR2) - GIT NOTE: "Phase 4 task 4.4: conftest.py parses sys.argv for --config BEFORE any src/ import. Auto-defaults to tests/artifacts/isolation_workspace<RUN_ID>/config_overrides.toml. registers via pytest_addoption so pytest doesn't warn."
- WHERE: Modify
-
Task 4.5: Write tests 11, 12, 13 in
tests/test_test_sandbox.py. [02fef00]- WHERE: Add to existing
tests/test_test_sandbox.py. - WHAT: Three tests for the
--configCLI flag behavior. - HOW:
def test_config_override_via_cli_flag(tmp_path) -> None: config_path = tmp_path / "my_config.toml" config_path.write_text("[ai]\nprovider='gemini'\n", encoding="utf-8") from src import paths original = paths._CONFIG_OVERRIDE try: paths.set_config_override(config_path) assert paths.get_config_path() == config_path finally: paths.set_config_override(original) def test_paths_get_config_path_no_env_fallback(monkeypatch) -> None: monkeypatch.delenv("SLOP_CONFIG", raising=False) from src import paths original = paths._CONFIG_OVERRIDE try: paths.set_config_override(None) root = Path(__file__).resolve().parent.parent assert paths.get_config_path() == root / "config.toml" finally: paths.set_config_override(original) def test_sloppy_py_parses_config_flag() -> None: import ast sloppy = Path(__file__).resolve().parent.parent / "sloppy.py" tree = ast.parse(sloppy.read_text(encoding="utf-8")) found_config = False for node in ast.walk(tree): if isinstance(node, ast.arg) and node.arg == "config": found_config = True assert found_config, "sloppy.py must have a --config argparse argument" - SAFETY: Tests manipulate
paths._CONFIG_OVERRIDEdirectly (private API but necessary for testing). Always restore infinallyblock. - COMMIT:
test(sandbox): add regression tests for --config CLI flag (Phase 4) - GIT NOTE: "Phase 4 task 4.5: 3 regression tests for FR2 (--config CLI flag, no env var fallback, sloppy.py argparse)."
- WHERE: Add to existing
-
Task 4.6: Phase 4 verification — run a broad smoke test. [deferred per user directive; static verification via AST + isolated paths.py import]
- WHERE: None.
- WHAT: Confirm sloppy.py (production) still launches with default config + tests still work with --config.
- HOW:
# Production: sloppy.py without --config uses default python sloppy.py --help # should NOT raise; --config appears in help # Tests: conftest auto-defaults to ./tests/artifacts/.../config_overrides.toml uv run python -m pytest tests/test_test_sandbox.py::test_config_override_via_cli_flag -v uv run python -m pytest tests/test_paths.py -v # existing tests still work - SAFETY: If sloppy.py crashes at import, the
--configordering is wrong. If existing tests fail, the new default breaks something — debug before committing. - COMMIT: None (this is verification, not a code change).
- GIT NOTE: None.
Phase 5: FR3 isolate_workspace + basetemp migration
Focus: Move the isolate_workspace workspace off %TEMP% to ./tests/artifacts/_isolation_workspace_<run_id>/. Add addopts = "--basetemp=..." to pyproject.toml. Update tech-stack.md note.
-
Task 5.1: Refactor
isolate_workspaceintests/conftest.py. [02fef00]- WHERE: Modify
tests/conftest.py:259-281(the existingisolate_workspaceautouse). - WHAT: Replace
tmp_path_factory.mktemp("isolated_workspace")withPath("tests/artifacts/_isolation_workspace") / _RUN_ID. AddSLOP_CREDENTIALS+SLOP_MCP_ENVenv vars. Auto-generate placeholder TOML files. - HOW:
Note: The
@pytest.fixture(autouse=True) def isolate_workspace(monkeypatch) -> Generator[None, None, None]: """Autouse fixture to isolate tests from the active user workspace. Workspace lives under tests/artifacts/ per workspace_paths.md.""" test_workspace = _ISOLATION_WORKSPACE # defined in conftest module body test_workspace.mkdir(parents=True, exist_ok=True) # Generate placeholder TOML files config_content = { "ai": {"provider": "gemini", "model": "gemini-2.5-flash-lite"}, "projects": {"paths": [], "active": ""}, "gui": {"show_windows": {}}, } with open(test_workspace / "config_overrides.toml", "wb") as f: tomli_w.dump(config_content, f) for name in ("credentials.toml", "mcp_env.toml", "presets.toml", "tool_presets.toml", "personas.toml", "workspace_profiles.toml"): (test_workspace / name).touch() monkeypatch.setenv("SLOP_CREDENTIALS", str(test_workspace / "credentials.toml")) monkeypatch.setenv("SLOP_MCP_ENV", str(test_workspace / "mcp_env.toml")) monkeypatch.setenv("SLOP_GLOBAL_PRESETS", str(test_workspace / "presets.toml")) monkeypatch.setenv("SLOP_GLOBAL_TOOL_PRESETS", str(test_workspace / "tool_presets.toml")) monkeypatch.setenv("SLOP_GLOBAL_PERSONAS", str(test_workspace / "personas.toml")) monkeypatch.setenv("SLOP_GLOBAL_WORKSPACE_PROFILES", str(test_workspace / "workspace_profiles.toml")) yieldtmp_path_factoryparameter is REMOVED from this fixture. Tests that legitimately need it should request it directly (def test_x(tmp_path): ...). - SAFETY: All env vars point INSIDE the isolation workspace, which is inside
./tests/artifacts/. The Layer 1 guard allows this. No test should break UNLESS it was relying on the previous%TEMP%path. - COMMIT:
refactor(tests): migrate isolate_workspace off tmp_path_factory to tests/artifacts/ (Phase 5, FR3) - GIT NOTE: "Phase 5 task 5.1: isolate_workspace fixture now creates tests/artifacts/isolation_workspace<RUN_ID>/. Adds SLOP_CREDENTIALS + SLOP_MCP_ENV env vars (previously only set in live_gui fixture). Per workspace_paths.md styleguide."
- WHERE: Modify
-
Task 5.2: Add
addoptstopyproject.toml. [1329723]- WHERE: Modify
pyproject.toml— add to[tool.pytest.ini_options]section. - WHAT: Add
addopts = "--basetemp=tests/artifacts/_pytest_tmp"so pytest'stmp_pathfactory uses a path under./tests/. - HOW: Insert:
[tool.pytest.ini_options] addopts = "--basetemp=tests/artifacts/_pytest_tmp" markers = [ ... ] - SAFETY: The basetemp directory is auto-created by pytest.
.gitignorealready hastests/artifacts/so it's gitignored. - COMMIT:
chore(pyproject): add --basetemp=tests/artifacts/_pytest_tmp addopts (Phase 5, FR3) - GIT NOTE: "Phase 5 task 5.2: pyproject.toml pytest addopts sets --basetemp to ./tests/artifacts/_pytest_tmp so all pytest tmp_path fixtures live under ./tests/."
- WHERE: Modify
-
Task 5.3: Defensive
_tmp_path_factory._basetempcheck inconftest.py:pytest_configure. [defensive check deemed unnecessary given the pyproject.toml addopts; addopts is the primary mechanism]- WHERE: Add to existing
pytest_configureintests/conftest.py(the one merged in Task 3.1). - WHAT: If
config._tmp_path_factory._basetempresolves outside./tests/, override to./tests/artifacts/_pytest_tmp. - HOW:
project_root = Path(__file__).resolve().parent.parent basetemp = getattr(config, "_tmp_path_factory", None) if basetemp is not None: current = Path(str(basetemp._basetemp)).resolve() if not str(current).startswith(str(project_root / "tests")): basetemp._basetemp = str(project_root / "tests" / "artifacts" / "_pytest_tmp") - SAFETY: Uses private API
_tmp_path_factory._basetemp— if pytest version changes, this breaks. Theaddoptsin Task 5.2 is the primary mechanism; this is defensive. - COMMIT: Same as 5.2 (combined).
- GIT NOTE: Same as 5.2.
- WHERE: Add to existing
-
Task 5.4: Add dated note to
conductor/tech-stack.md.- WHERE: Modify
conductor/tech-stack.md— append a dated note to the pytest section. - WHAT: Explain the
--basetempchoice and referenceworkspace_paths.md. - HOW:
## pyproject.toml pytest addopts (added 2026-06-19, per test_sandbox_hardening_20260619) `[tool.pytest.ini_options].addopts = "--basetemp=tests/artifacts/_pytest_tmp"`. **Rationale:** Per `conductor/code_styleguides/workspace_paths.md`, ALL test infrastructure paths must live under `./tests/`. pytest's `tmp_path` and `tmp_path_factory` fixtures default to `%TEMP%\pytest-of-<user>\` on Windows. This `addopts` redirects them under `./tests/` so the Layer 1 runtime guard's allowlist (also `./tests/`) can be a single rule. - SAFETY: Pure documentation change.
- COMMIT:
docs(tech-stack): note --basetemp addopts rationale (Phase 5, FR3) - GIT NOTE: Same as 5.2.
- WHERE: Modify
-
Task 5.5: Write tests 7, 8, 9 in
tests/test_test_sandbox.py. [9484aae]- WHERE: Add to existing
tests/test_test_sandbox.py. - WHAT: Three tests verifying pyproject.toml, isolate_workspace, and AppController invariant.
- HOW:
def test_pyproject_toml_basetemp_is_under_tests() -> None: pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" text = pyproject.read_text(encoding="utf-8") assert "--basetemp=tests/artifacts/_pytest_tmp" in text def test_isolate_workspace_does_not_use_tmp_path_factory_for_infra() -> None: import ast conftest = Path(__file__).resolve().parent / "conftest.py" tree = ast.parse(conftest.read_text(encoding="utf-8")) for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name == "isolate_workspace": src = ast.unparse(node) assert "tmp_path_factory.mktemp" not in src, ( "isolate_workspace must not use tmp_path_factory.mktemp; " "use Path('tests/artifacts/_isolation_workspace') / _RUN_ID" ) return raise AssertionError("isolate_workspace fixture not found in conftest.py") def test_appcontroller_init_does_not_load_config() -> None: import ast app_controller = Path(__file__).resolve().parent.parent / "src" / "app_controller.py" tree = ast.parse(app_controller.read_text(encoding="utf-8")) for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name == "__init__": src = ast.unparse(node) assert "init_state()" not in src, ( "AppController.__init__ must not call init_state() " "(this would trigger config reads before fixtures apply)" ) assert "load_config()" not in src, ( "AppController.__init__ must not call load_config() " "(this would trigger config reads before fixtures apply)" ) return raise AssertionError("AppController.__init__ not found") - SAFETY: These tests are static AST checks; they parse source files. They fail loud if invariants break. The
init_state()invariant test is critical per FR2 audit. - COMMIT:
test(sandbox): add regression tests for FR3 invariants (Phase 5) - GIT NOTE: "Phase 5 task 5.5: 3 regression tests for FR3 (pyproject basetemp, isolate_workspace no tmp_path_factory, AppController.init invariant)."
- WHERE: Add to existing
-
Task 5.6: Phase 5 verification — run Tier-2 + Tier-3 to confirm no regression.
- WHERE: None.
- WHAT: Verify the basetemp migration + isolate_workspace migration don't break existing tests.
- HOW:
uv run python scripts/run_tests_batched.py --tiers 2,3 --timeout 180 - SAFETY: If tests fail, check whether they were using
tmp_path(which now resolves under./tests/) or hardcoded paths to%TEMP%(which the Layer 1 guard now blocks). Audit the failing test, don't disable the guard. - COMMIT: None.
- GIT NOTE: None.
Phase 6: FR5 PowerShell Wrapper (OPT-IN)
Focus: Write scripts/run_tests_sandboxed.ps1 (Windows-only, opt-in) that wraps pytest in a Windows restricted token + Job Object.
-
Task 6.1: Write
scripts/run_tests_sandboxed.ps1. [dc5afc2]- WHERE: Create
scripts/run_tests_sandboxed.ps1. - WHAT: Mirror
scripts/tier2/run_tier2_sandboxed.ps1structure (100 lines). Replace OpenCode launch with pytest launch. - HOW: Tier 3 worker MUST read
scripts/tier2/run_tier2_sandboxed.ps1end-to-end first (per writing-plans skill "Read Reference Implementation COMPLETELY"), then copy its Add-Type / Job Object / token-acquisition blocks verbatim. Only the LAST step (the actual process launch) differs. Full template:The Add-Type / token / Job Object blocks MUST be copied verbatim from# scripts/run_tests_sandboxed.ps1 <# .SYNOPSIS Run pytest in a Windows restricted-token sandbox. .DESCRIPTION Acquires a Windows restricted token (drops dangerous privileges), wraps pytest in a Job Object, and runs the test suite. The test workspace is forced under ./tests/ via the --config and --basetemp flags (handled by the conftest.py autouse fixtures). The Tier 2 clone at <ProjectRoot> is the only directory pytest can read/write for tests; everything outside ./tests/ is blocked by the Layer 1 Python guard PLUS the restricted-token enforcement. .NOTES Requires Windows + PowerShell 7+ + admin privileges for full restricted-token acquisition. The -WhatIf mode is a no-op dry-run (exits 0 without acquiring a token). .LINK scripts/tier2/run_tier2_sandboxed.ps1 (template) conductor/tracks/test_sandbox_hardening_20260619/spec.md (FR5) #> [CmdletBinding()] param( [switch]$WhatIf, [string]$TestPath = "tests/", [string]$ConfigPath = "" # empty = conftest.py auto-defaults to config_overrides.toml ) $ErrorActionPreference = "Stop" $ProjectRoot = (Resolve-Path "$PSScriptRoot/..").Path if ($WhatIf) { Write-Host "[SANDBOX-WHATIF] Would run pytest in restricted token at $ProjectRoot" Write-Host "[SANDBOX-WHATIF] TestPath: $TestPath" Write-Host "[SANDBOX-WHATIF] ConfigPath: $($ConfigPath) (empty = conftest.py auto-defaults)" exit 0 } # === BEGIN: copy Add-Type / token / Job Object blocks from === # === scripts/tier2/run_tier2_sandboxed.ps1 lines 30-95 verbatim === # (See reference script for the full restricted-token + Job Object setup.) # === END: tier2 clone blocks === # Invoke pytest under restricted token with sandbox flags. # The --basetemp flag ensures pytest's tmp dirs live under ./tests/. # The --config flag points to a config_overrides.toml inside ./tests/ # (or empty = conftest.py auto-defaults). $argList = @( "run", "python", "-m", "pytest", $TestPath, "--basetemp=tests/artifacts/_pytest_tmp" ) if ($ConfigPath -ne "") { $argList += "--config=$ConfigPath" } Push-Location $ProjectRoot try { & uv @argList } finally { Pop-Location }scripts/tier2/run_tier2_sandboxed.ps1lines 30-95 (or wherever the equivalent code lives in the latest version of that script — Tier 3 worker should re-read the source). Only the LAST block (the actual invocation) is new. - SAFETY:
-WhatIfmode is a no-op (exits 0). Full PowerShell restricted-token wrapper requires admin privileges on Windows; document this in the script header. The script is OPT-IN — users continue to useuv run pytestoruv run python scripts/run_tests_batched.pyfor normal test runs. - COMMIT:
feat(scripts): add scripts/run_tests_sandboxed.ps1 (Phase 6, FR5 opt-in) - GIT NOTE: "Phase 6 task 6.1: PowerShell wrapper for Windows restricted-token + Job Object pytest sandbox. Mirrors run_tier2_sandboxed.ps1 structure (Add-Type + token + Job Object blocks copied verbatim). Only the invocation differs (pytest instead of OpenCode). -WhatIf mode for dry-run. OPT-IN."
- WHERE: Create
-
Task 6.2: Write a smoke test for
-WhatIfmode. [dc5afc2]- WHERE: Add to
tests/test_test_sandbox.py(as test 14). - WHAT: Verify
pwsh -File scripts/run_tests_sandboxed.ps1 -WhatIfexits 0. - HOW:
@pytest.mark.skipif(os.name != "nt", reason="Windows-only sandbox wrapper") def test_run_tests_sandboxed_whatif() -> None: result = subprocess.run( ["pwsh", "-File", "scripts/run_tests_sandboxed.ps1", "-WhatIf"], capture_output=True, text=True, ) assert result.returncode == 0, f"Expected exit 0, got {result.returncode}: {result.stderr}" - SAFETY: Skipped on non-Windows per
conductor/workflow.mdSkip-Marker Policy (legitimate opt-in integration test, requires Windows + pwsh). - COMMIT: Same as 6.1.
- GIT NOTE: Same as 6.1.
- WHERE: Add to
Phase 7: FR7 Documentation
Focus: Document the 4-layer enforcement model + --config CLI flag convention + config_overrides.toml naming.
-
Task 7.1: Create
conductor/code_styleguides/test_sandbox.md. [5d29e40]- WHERE: Create
conductor/code_styleguides/test_sandbox.md. - WHAT: Styleguide document covering: the
--configCLI flag,config_overrides.tomlconvention, 4-layer enforcement model,--basetemprule, Layer 1 audit hook contract, opt-inrun_tests_sandboxed.ps1, audit script. - HOW: Use elements-of-style:writing-clearly-and-concisely (the existing styleguides in
conductor/code_styleguides/are good templates). Sections: TL;DR; The 4-Layer Model;--configCLI Flag (replaces SLOP_CONFIG);--basetempRule; Layer 1 Audit Hook Contract; Static Audit; OS-Level Wrapper; Test Workspace Convention (config_overrides.toml); See Also. - SAFETY: Documentation only. Reference actual file:line locations from the spec.
- COMMIT:
docs(styleguide): add test_sandbox.md (Phase 7, FR7) - GIT NOTE: "Phase 7 task 7.1: new styleguide test_sandbox.md documents the 4-layer enforcement model, --config CLI flag, config_overrides.toml convention, --basetemp rule."
- WHERE: Create
-
Task 7.2: Update
conductor/code_styleguides/workspace_paths.md. [5d29e40]- WHERE: Append a section to the existing file.
- WHAT: Mention the
SLOP_CONFIG → --configmigration +pytest --basetempaddopts. - HOW: Add a "2026-06-19 Update" section at the bottom.
- SAFETY: Documentation only.
- COMMIT: Same as 7.1.
- GIT NOTE: Same as 7.1.
-
Task 7.3: Add
Sandbox Hardeningsection todocs/guide_testing.md. [5d29e40]- WHERE: Modify
docs/guide_testing.md— add a new section. - WHAT: Cross-reference to
test_sandbox.md+ summary of the 4 layers. - HOW: Append the section.
- SAFETY: Documentation only.
- COMMIT: Same as 7.1.
- GIT NOTE: Same as 7.1.
- WHERE: Modify
Phase 8: Full Suite Verification
Focus: Run the full 11-tier suite and confirm no regression vs. the 1288 passed + 4 xdist-skipped baseline.
-
Task 8.1: Run full test suite.
- WHERE: None.
- WHAT: Run all 11 tiers and capture results.
- HOW:
uv run python scripts/run_tests_batched.py --tiers 1,2,3,4,5,6,7,8,9,10,11 > tests/artifacts/_full_suite_post_sandbox.txt 2>&1 - SAFETY: If regression vs. baseline (1288 + 4), STOP and report to user. Do not commit a broken suite. Per
conductor/workflow.mdPhase Completion Verification protocol. - COMMIT: None (verification).
- GIT NOTE: None.
-
Task 8.2: Commit verification report.
- WHERE: None (commit the baseline diff comparison).
- WHAT: Stage
tests/artifacts/_full_suite_post_sandbox.txtas a verification artifact. - HOW:
git add tests/artifacts/_full_suite_post_sandbox.txt; git commit -m "conductor(checkpoint): Phase 8 - full suite green, no regression vs. baseline 1288+4" - SAFETY: If regression occurred in 8.1, fix forward or roll back per
conductor/workflow.mdPer-Task Decision Protocol. - COMMIT: As above.
- GIT NOTE: "Phase 8 checkpoint: full 11-tier suite passed. No regression vs. pre-track baseline (1288 + 4). Test sandbox hardening is operational."
Phase 9: End-of-Track Report
Focus: Write the completion report following the precedent set by TRACK_COMPLETION_tier2_autonomous_sandbox_20260616.md. Update state.toml to completed.
-
Task 9.1: Write
docs/reports/TRACK_COMPLETION_test_sandbox_hardening_20260619.md.- WHERE: Create
docs/reports/TRACK_COMPLETION_test_sandbox_hardening_20260619.md. - WHAT: Track completion report with: scope (files added/modified), pass-rate baseline + post, deferred items, lessons learned, follow-up tracks (other SLOP_* env vars), user review gate.
- HOW: Mirror the structure of
docs/reports/TRACK_COMPLETION_tier2_autonomous_sandbox_20260616.md. - SAFETY: Pure documentation.
- COMMIT:
docs(reports): TRACK_COMPLETION_test_sandbox_hardening_20260619 (Phase 9) - GIT NOTE: "Phase 9: track completion report. 9 phases shipped. 4-layer test sandbox enforcement operational. Deferred: convert other SLOP_* env vars to CLI flags (separate mess, separate tracks)."
- WHERE: Create
-
Task 9.2: Update
state.tomland commit.- WHERE: Modify
conductor/tracks/test_sandbox_hardening_20260619/state.toml. - WHAT: Set
status = "completed",current_phase = "complete". - HOW:
[meta] status = "completed" current_phase = "complete" last_updated = "2026-06-19" - SAFETY: Pure metadata.
- COMMIT:
conductor(state): mark test_sandbox_hardening_20260619 complete - GIT NOTE: "Phase 9 final: state.toml marked complete. Track ships."
- WHERE: Modify
Summary
| Phase | Tasks | Key output | Risk |
|---|---|---|---|
| 1: Investigation | 3 | Baseline pass count + audit of get_config_path() callers | None (read-only) |
| 2: FR4 Static audit | 3 | scripts/audit_test_sandbox_violations.py + 3 tests |
Low |
| 3: FR1 Python guard | 3 | _enforce_test_sandbox fixture + 4 tests |
High (can break tests) |
| 4: FR2 Root-cause fix | 6 | set_config_override() + --config CLI flag + 3 tests |
High (root-cause) |
| 5: FR3 Isolation migration | 6 | isolate_workspace + --basetemp + tech-stack.md + 3 tests |
Medium |
| 6: FR5 PowerShell | 2 | scripts/run_tests_sandboxed.ps1 + smoke test |
Low (opt-in) |
| 7: FR7 Documentation | 3 | test_sandbox.md + updates |
None |
| 8: Verification | 2 | 11-tier pass count + checkpoint commit | Verification only |
| 9: Report | 2 | TRACK_COMPLETION_* + state.toml completed |
None |
Total: 30 tasks across 9 phases, ~11 atomic commits.
TDD per phase: Red (write failing test) → Green (minimal impl) → Verify → Commit.
Per-task discipline: WHERE / WHAT / HOW / SAFETY / COMMIT / GIT NOTE per conductor/workflow.md Tier 1 rules.
Hard bans: No git restore, git checkout, git reset. No day estimates in commit messages or git notes. No diagnostic noise in src/*.py. No new @pytest.mark.skip markers except the one for test_run_tests_sandboxed_whatif (Windows-only, legitimate per conductor/workflow.md Skip-Marker Policy).
Rollback: Each phase is a separate commit. If any phase breaks, git revert the phase's commit(s) without affecting the others.
Handoff to Tier 2
This plan is executed by a Tier 2 Tech Lead via the standard conductor/workflow.md Task Workflow:
- Activate
mma-orchestratorskill. - For each task: read context, write code, run tests, commit per
git commitline, attach git note. - After each phase: phase completion verification + checkpoint.
- After Phase 9: track complete; user reviews merge per
conductor/workflow.md"Review and merge workflow".
Tier 3 workers (via scripts/mma_exec.py --role tier3-worker) handle individual tasks with surgical prompts. The Tier 2 Tech Lead reviews each commit before moving to the next task.