# Test Sandbox Hardening — Hard Rule ## TL;DR The Manual Slop test suite runs under a 4-layer sandbox that prevents any pytest invocation from writing files outside `./tests/`. The root-cause fix removes the historical `SLOP_CONFIG` env-var fallback in favor of an explicit `--config` CLI flag. Any test that needs a config file must point at one inside `./tests/artifacts/`. ## The 4-Layer Model | Layer | Mechanism | Where | Default-on? | |---|---|---|---| | Layer 1 | Python runtime file-I/O guard (`sys.addaudithook`) | `tests/conftest.py:_sandbox_audit_hook` | Yes | | Layer 2 | `isolate_workspace` autouse + `pyproject.toml --basetemp` | `tests/conftest.py` + `pyproject.toml` | Yes | | Layer 3 | OS-level restricted-token PowerShell wrapper | `scripts/run_tests_sandboxed.ps1` | **Opt-in** | | Layer 4 | Static audit script (CI gate) | `scripts/audit_test_sandbox_violations.py` | Yes (informational) / opt-in (`--strict`) | Layer 1 + Layer 2 + Layer 4 are file-presence-on = enabled (delete the relevant file to disable). Layer 3 requires explicit invocation. ## The `--config` CLI Flag (replaces `SLOP_CONFIG`) The historical `SLOP_CONFIG` env var has been removed from `src/paths.py`. The CLI flag `--config ` is now the ONLY supported mechanism for overriding the default `/config.toml` location. ### sloppy.py ```bash # Use the default /config.toml uv run python sloppy.py # Override uv run python sloppy.py --config /path/to/your/config.toml ``` `sloppy.py` calls `paths.set_config_override(Path(args.config).resolve())` AFTER `parse_args()` and BEFORE any `from src.gui_2 import App` import. This is the only way to override the config path in production. ### tests/conftest.py `tests/conftest.py` parses `sys.argv` for `--config` at MODULE BODY (BEFORE any `src/` import). If `--config` is not passed, conftest auto-defaults to `tests/artifacts/_isolation_workspace_/config_overrides.toml` (which lives inside `./tests/artifacts/`, so the Layer 1 guard allows writes to it). ```python # Module body in tests/conftest.py (BEFORE any src/ import) def _parse_config_arg(argv: list[str]) -> Path | None: for i in range(1, len(argv)): arg = argv[i] if arg == "--config" and i + 1 < len(argv): return Path(argv[i + 1]).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" from src import paths as _paths # noqa: E402 _paths.set_config_override(_config_override_arg) ``` The fixture also auto-generates a placeholder `config_overrides.toml` (with `ai.provider`, `projects`, `gui.show_windows`) so src/ code that reads the config at startup does not crash. ## The `--basetemp` Rule `pyproject.toml` sets `addopts = "--basetemp=tests/artifacts/_pytest_tmp"`. This redirects pytest's `tmp_path` and `tmp_path_factory` fixtures (which default to `%TEMP%\pytest-of-\` on Windows) into `./tests/artifacts/`. This is what allows the Layer 1 allowlist to be a single rule: "anything under `./tests/` is allowed." ## Layer 1 Audit Hook Contract `tests/conftest.py:_sandbox_audit_hook` is a `sys.addaudithook` callback. It fires on every `open()` call. Behavior: - **Reads** (mode `r`, `rb`): pass through, no check - **Writes** (mode contains `w`, `a`, `x`, `+`): check path - **Allowed** if path resolves under: - `/tests/` - Path contains `.pytest_cache`, `__pycache__`, `.coverage`, `.slop_cache`, or `.ruff_cache` - Original path string starts with `\\.\` (Windows device namespace) or `/dev/` (Unix device namespace) - **Blocked** otherwise: raises `RuntimeError("TEST_SANDBOX_VIOLATION: attempted to write to ...")` **How to fix a violation:** - Move the write under `/tests/` (use `tmp_path`, `tests/artifacts/_/`, etc.) - For pytest internal files (cache, log): check if the path is in the allowlist; if not, open an issue to add it ## Layer 2 Workspace Convention (`config_overrides.toml`) Tests that need a `config.toml` should use the auto-generated `tests/artifacts/_isolation_workspace_/config_overrides.toml`. The naming convention `config_overrides.toml` (instead of `config.toml`) signals that this file is an override for tests, not the production config. Tests CAN pass `--config /some/other/path.toml` explicitly; conftest will honor it. But the default is fine for most cases. ## Layer 3 Opt-in OS-Level Wrapper `scripts/run_tests_sandboxed.ps1` is the Windows-only restricted-token + Job Object wrapper for paranoid users. It mirrors `scripts/tier2/run_tier2_sandboxed.ps1`: ```bash # Dry-run (no actual sandbox; just prints what would happen) pwsh -File scripts/run_tests_sandboxed.ps1 -WhatIf # Run the full suite in the sandbox pwsh -File scripts/run_tests_sandboxed.ps1 # Run a specific test path pwsh -File scripts/run_tests_sandboxed.ps1 -TestPath tests/test_paths.py # Override config explicitly pwsh -File scripts/run_tests_sandboxed.ps1 -ConfigPath /some/path/config.toml ``` The wrapper: 1. Acquires a restricted token via .NET DuplicateTokenEx 2. Sets cwd to `` 3. Invokes `uv run python -m pytest $TestPath --basetemp=tests/artifacts/_pytest_tmp [--config=...]` 4. Forwards pytest exit code ## Layer 4 Static Audit `scripts/audit_test_sandbox_violations.py` scans `tests/test_*.py` for hardcoded paths that would corrupt user files: - `Path("manual_slop.toml")`, `Path("config.toml")`, `Path("credentials.toml")`, `Path("presets.toml")`, etc. - `open("manual_slop.toml", "w")` and similar write-mode calls - `Path("C:/projects/...")` and `Path("C:\\projects\\...")` - `Path("tests/artifacts/...")` literal (violates workspace_paths.md; should use a fixture) - `tempfile.mkdtemp()`, `tempfile.mkstemp()` (without `dir=`) Default mode (informational) exits 0 and lists violations. `--strict` mode (CI gate) exits 1 on any violation. ```bash # Informational uv run python scripts/audit_test_sandbox_violations.py # CI gate uv run python scripts/audit_test_sandbox_violations.py --strict ``` ## Why This Rule Exists The user has lost "important sample data" multiple times over the past month because tests have written to `manual_slop.toml`, `manual_slop_history.toml`, `personas.toml`, `presets.toml`, `tool_presets.toml`, or `credentials.toml` at the top of the repo. The root cause was the silent `SLOP_CONFIG` env-var fallback in `src/paths.py` — any test could set the env var and have `paths.get_config_path()` return a project-root file. This track fixes that and adds defense in depth. ## Forbidden Patterns (Hard Bans) ### 1. `SLOP_CONFIG` env var Setting `SLOP_CONFIG` no longer affects `paths.get_config_path()`. Use `--config` instead. ### 2. `tempfile.mkdtemp()` / `tempfile.mkstemp()` without `dir=` These default to `%TEMP%`, which the Layer 1 guard blocks. Use: - `tempfile.mkdtemp(dir="tests/artifacts/")` (explicit under tests) - `tmp_path` pytest fixture (resolves under `--basetemp`) - `tmp_path_factory.mktemp("name")` (same) ### 3. Writing to `/*.toml` or `/*.ini` The Layer 1 guard raises `TEST_SANDBOX_VIOLATION` on any write to a top-level TOML/INI file. Move the file under `tests/artifacts/`. ### 4. `Path(__file__).parent.parent / "config.toml"` This pattern is a `..` traversal to the project root. Flagged by Layer 4 static audit. ## Audit Enforcement - **Layer 4** runs as a pre-commit hook + CI gate (`--strict` mode) - **Layer 1** fires at pytest runtime; cannot be bypassed without deleting `tests/conftest.py:_sandbox_audit_hook` - **Layer 2** is enforced by `pyproject.toml` addopts; cannot be overridden per-invocation ## See Also - `conductor/code_styleguides/workspace_paths.md` — the existing test-workspace rule (extended by this track) - `conductor/code_styleguides/feature_flags.md` — file-presence = enabled convention - `conductor/tech-stack.md` §"pyproject.toml pytest addopts" — dated note explaining `--basetemp` - `scripts/audit_no_temp_writes.py` — pattern reference for Layer 4 audit - `scripts/tier2/run_tier2_sandboxed.ps1` — pattern reference for Layer 3 wrapper - `conductor/tracks/test_sandbox_hardening_20260619/` — this track's spec + plan + state - `conductor/tracks/workspace_path_finalize_20260609/` — prior track that established `tests/artifacts/` workspace pattern