Skip to content

Commit e7d8326

Browse files
fix(sandbox): give the sandbox venv system site packages (#162)
`SandboxConfig.python` defaults to a `PythonEnvConfig()` instance, so `setup()` builds a venv for every task and installs nothing into it unless `env_packages` is set. That empty venv is not neutral. It goes on the criterion PATH via `_build_run_command_env`, so inside a task image that provisions packages globally the toolchain splits: which pip -> /usr/local/bin/pip (image global; `uv venv` seeds none in the venv) pip list -> langchain 1.3.14 (present!) python -c "import langchain" -> ModuleNotFoundError Measured cost, run 2026-09-10_04-18-49, `skill-agent-guardrail-coded-escalation-smoke`: the agent finished the guardrail at turn 19, spent turns 22-31 chasing that contradiction, repaired it with `uv sync`, then hit `max_turns: 40` at turn 41 before writing the app resource into bindings.json. Scored 0.80 with correct code. Create the venv with system site packages instead of removing it. The image's globals stay importable, `python` and `pip` agree, and installs still land in the venv (`sys.prefix` remains the sandbox), so a task's `env_packages` cannot leak into the image. Verified in a task image: `import pydantic` exits 1 on main and 0 here, with `sys.prefix` still the sandbox venv. Verified on the host that a criterion still cannot `import coder_eval`, so the grader's own environment does not leak in either -- `uv venv` bases on the system interpreter, not the active one. An earlier revision of this branch fixed the same defect by not creating the venv at all. Review found that cure wider than the disease: it desynchronized `setup` from `Sandbox.adopt` (which gates on `config.python` alone), so one trajectory could score differently under `run` vs `evaluate --in-place`, and it dropped the harness's `python`-on-PATH guarantee that 15 in-tree `run_command` criteria rely on. Keeping the venv keeps both gates identical and the guarantee intact. Also from that review: - The stdlib fallback produces a different artifact (it seeds pip) with no log and no coverage; it now warns and carries the same system-site-packages setting. - Tests assert the effect rather than the artifact: `pyvenv.cfg` carries `include-system-site-packages`, and `_build_run_command_env()` carries VIRTUAL_ENV plus the venv bin prefix. - `test_template_ignores_venv`'s only `.venv` assertion sat behind `if venv_bin.exists():` and could go vacuous; it is now unconditional, via a marker file the real venv can never contain. The marker is also what keeps it portable -- asserting on `.venv/bin/python` holds only on POSIX, since a real venv puts its interpreter in `Scripts/python.exe` on Windows. - `python: null` is documented user-facing behavior whose adopt arm was unexercised across the whole suite; it now has a test. - New live docker test pins the motivating scenario end to end, which nothing in CI asserted before. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3aca8e8 commit e7d8326

7 files changed

Lines changed: 239 additions & 13 deletions

File tree

‎docs/TASK_DEFINITION_GUIDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,8 @@ compares a truncated run against a full one):
518518

519519
The `sandbox` block is optional. When omitted, it defaults to `driver: "tempdir"` with standard Python environment.
520520
521+
The sandbox venv is created with **system site packages**, so `run_command` criteria (and `pre_run`/`post_run`) can import packages the task image installed globally while anything `env_packages` adds still lands in the venv. An isolated venv would shadow the image's interpreter without providing a replacement.
522+
521523
```yaml
522524
sandbox:
523525
driver: "tempdir" # Sandbox type ("tempdir" or "docker"); default: "tempdir"

‎src/coder_eval/models/sandbox.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,12 @@ class SandboxConfig(BaseModel):
597597
)
598598
python: PythonEnvConfig | None = Field(
599599
default_factory=PythonEnvConfig,
600-
description="Python environment config; set to null in YAML (or None in Python) to skip venv creation",
600+
description=(
601+
"Python environment config. A venv is created whenever this block is present, with access to "
602+
"system site packages so criteria can import what the task image installed globally (an isolated "
603+
"venv shadows the interpreter while providing nothing). Set to null in YAML (or None in Python) "
604+
"to skip venv creation, and to opt out of adopting a venv the agent created itself."
605+
),
601606
)
602607
node: NodeEnvConfig | None = Field(
603608
default=None,

‎src/coder_eval/sandbox.py‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,9 @@ def _setup_tempdir(self, target_dir: Path | None = None) -> Path:
406406
# Mark mock binaries executable so the agent's PATH can shadow real CLIs
407407
self._prepare_mock_path_dirs()
408408

409-
# Set up Python virtual environment (only if python config is provided)
409+
# Set up Python virtual environment (only if python config is provided).
410+
# The venv is created with system site packages -- see _setup_virtualenv
411+
# for why an isolated one was actively harmful.
410412
if self.config.python:
411413
self._setup_virtualenv()
412414

@@ -835,7 +837,29 @@ def _matches_template_include_pattern(self, rel_path: Path, include_patterns: li
835837
return False
836838

837839
def _setup_virtualenv(self) -> None:
838-
"""Create a Python virtual environment in the sandbox."""
840+
"""Create a Python virtual environment in the sandbox, with system site packages.
841+
842+
``--system-site-packages`` is load-bearing, not a convenience. An ISOLATED
843+
venv here shadows the interpreter while providing nothing: the sandbox venv
844+
goes on the criterion PATH (``_build_run_command_env``, which governs every
845+
``run_command`` criterion plus ``pre_run``/``post_run``), so inside a task
846+
image that provisions packages globally, ``python`` resolved to the empty
847+
venv and could not import them while ``pip`` -- which ``uv venv`` does not
848+
place in the venv at all -- fell through to the image's global pip and
849+
reported them present. Measured in a task image: ``import langchain`` raised
850+
``ModuleNotFoundError`` while ``pip list`` showed ``langchain 1.3.14``. An
851+
agent that tried to verify its own work chased that contradiction for ten
852+
turns and ran out of budget before finishing.
853+
854+
Note the venv is NOT on the agent's own PATH -- the orchestrator prepends
855+
only ``resolved_mock_path_dirs`` there -- so the contradiction above is a
856+
property of criterion and pre/post-run subprocesses.
857+
858+
System site packages fixes it in the direction that keeps both halves: the
859+
image's globals stay importable, ``python`` and ``pip`` agree, and installs
860+
still land in the venv (``sys.prefix`` remains the sandbox), so a task's
861+
``env_packages`` cannot leak into the image.
862+
"""
839863
if not self.sandbox_dir:
840864
raise RuntimeError("Sandbox directory not initialized")
841865

@@ -846,13 +870,16 @@ def _setup_virtualenv(self) -> None:
846870
# Check if uv is available
847871
subprocess.run(["uv", "--version"], check=True, capture_output=True, timeout=5)
848872
# Use uv to create venv
849-
cmd = ["uv", "venv", str(self.venv_dir)]
873+
cmd = ["uv", "venv", "--system-site-packages", str(self.venv_dir)]
850874
subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=60)
851875
except (subprocess.CalledProcessError, FileNotFoundError):
852-
# Fallback to standard venv if uv is not available
876+
# Fallback to standard venv if uv is not available. The two paths do not
877+
# produce the same artifact -- this one seeds pip, `uv venv` does not --
878+
# so say which shape this host got rather than leaving it to be inferred.
853879
import venv
854880

855-
venv.create(self.venv_dir, with_pip=True)
881+
logger.warning("uv unavailable; created %s with stdlib venv (pip seeded)", self.venv_dir)
882+
venv.create(self.venv_dir, with_pip=True, system_site_packages=True)
856883

857884
def _install_packages(self) -> None:
858885
"""Install required Python packages in the virtual environment."""

‎tests/test_sandbox.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,12 @@ def test_sandbox_with_packages():
486486
sandbox = Sandbox(config, task_id="test_packages")
487487

488488
try:
489-
sandbox.setup()
489+
sandbox_dir = sandbox.setup()
490+
491+
# Asking for packages is what earns a venv (see
492+
# test_default_python_config_creates_no_venv_when_nothing_to_install).
493+
assert (sandbox_dir / ".venv").exists()
494+
assert sandbox.venv_dir == sandbox_dir / ".venv"
490495

491496
# Test that requests is installed
492497
exit_code, stdout, stderr = sandbox.run_command('python -c "import requests; print(requests.__version__)"')
@@ -1310,3 +1315,53 @@ def test_absent_when_the_task_declares_no_reference(self, tmp_path):
13101315
assert "REFERENCE_DIR" not in sb._build_run_command_env()
13111316
finally:
13121317
sb.cleanup(preserve=False)
1318+
1319+
1320+
def test_default_venv_can_import_system_site_packages():
1321+
"""The sandbox venv must not shadow the interpreter it is layered over.
1322+
1323+
`SandboxConfig.python` defaults to a `PythonEnvConfig()` instance, so every
1324+
task gets a venv. Built ISOLATED, that venv split the toolchain inside a task
1325+
image that provisions packages globally: `python` resolved to the venv and
1326+
could not import them, while `pip` -- which `uv venv` never places in the venv
1327+
-- fell through to the image's global pip and reported them present. Measured
1328+
in a task image: `import langchain` raised ModuleNotFoundError while
1329+
`pip list` showed `langchain 1.3.14`.
1330+
1331+
Asserted through `pyvenv.cfg` rather than a live import so the test is
1332+
hermetic: it holds on a host whose base interpreter has nothing installed.
1333+
"""
1334+
config = SandboxConfig(driver="tempdir")
1335+
assert config.python is not None, "default is an instance, not None -- the case this guards"
1336+
assert config.python.env_packages == []
1337+
1338+
sandbox = Sandbox(config, task_id="test_system_site_packages")
1339+
try:
1340+
sandbox_dir = sandbox.setup()
1341+
cfg = (sandbox_dir / ".venv" / "pyvenv.cfg").read_text(encoding="utf-8")
1342+
assert "include-system-site-packages = true" in cfg.lower()
1343+
finally:
1344+
sandbox.cleanup()
1345+
1346+
1347+
def test_venv_reaches_the_criterion_environment():
1348+
"""The venv is only worth creating because criteria run under it.
1349+
1350+
`_build_run_command_env` is the single surface that carries it (its sole
1351+
caller is `Sandbox.run_command`, i.e. every `run_command` criterion plus
1352+
`pre_run`/`post_run`). The agent's own PATH never carries the venv -- the
1353+
orchestrator prepends only `resolved_mock_path_dirs` there -- so asserting
1354+
the artifact exists says nothing about the effect this change exists for.
1355+
"""
1356+
sandbox = Sandbox(SandboxConfig(driver="tempdir"), task_id="test_criterion_env")
1357+
try:
1358+
sandbox_dir = sandbox.setup()
1359+
venv_dir = sandbox_dir / ".venv"
1360+
assert sandbox.venv_dir == venv_dir
1361+
1362+
env = sandbox._build_run_command_env()
1363+
assert env["VIRTUAL_ENV"] == str(venv_dir)
1364+
scripts_dir = "Scripts" if os.name == "nt" else "bin"
1365+
assert env["PATH"].startswith(f"{venv_dir / scripts_dir}{os.pathsep}")
1366+
finally:
1367+
sandbox.cleanup()

‎tests/test_sandbox_adopt.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,28 @@ def test_adopt_leaves_venv_unset_when_there_is_none(tmp_path: Path) -> None:
8888
assert sandbox.venv_dir is None
8989

9090

91+
def test_adopt_ignores_a_venv_when_python_is_null(tmp_path: Path) -> None:
92+
"""`python: null` opts out of BOTH halves: setup creates no venv, and adopt
93+
declines to pick up one the agent wrote itself.
94+
95+
Without this, the false arm of adopt's gate was unexercised across the whole
96+
suite while the field description documents it as user-facing behavior. The
97+
assertion is on the criterion environment, not just `venv_dir`, because that
98+
is the surface the opt-out exists to control.
99+
"""
100+
ws = _workspace(tmp_path)
101+
(ws / ".venv" / "bin").mkdir(parents=True)
102+
sandbox = _sandbox(python=None)
103+
sandbox.adopt(ws)
104+
assert sandbox.venv_dir is None
105+
# `_build_run_command_env` starts from `os.environ.copy()`, so an ambient
106+
# VIRTUAL_ENV from the grader's own shell can be present; what must not
107+
# happen is the harness pointing either variable at the WORKSPACE venv.
108+
env = sandbox._build_run_command_env()
109+
assert env.get("VIRTUAL_ENV") != str(ws.resolve() / ".venv")
110+
assert str(ws.resolve() / ".venv") not in env["PATH"]
111+
112+
91113
def test_adopt_rejects_the_docker_driver(tmp_path: Path) -> None:
92114
"""A container workspace is not reachable from the host, so adopting one
93115
would silently grade whatever happens to sit at that host path."""

‎tests/test_sandbox_templates.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def test_template_ignores_venv(self, tmp_path):
164164
(template_dir / ".venv").mkdir()
165165
(template_dir / ".venv" / "bin").mkdir()
166166
(template_dir / ".venv" / "bin" / "python").write_text("fake python")
167+
(template_dir / ".venv" / "from_template.marker").write_text("copied")
167168

168169
config = SandboxConfig(
169170
driver="tempdir",
@@ -179,12 +180,16 @@ def test_template_ignores_venv(self, tmp_path):
179180
# Verify main.py copied
180181
assert (sandbox_path / "main.py").exists()
181182

182-
# Verify .venv from template was NOT copied
183-
# (sandbox creates its own .venv)
184-
venv_bin = sandbox_path / ".venv" / "bin"
185-
if venv_bin.exists():
186-
# If .venv exists, it should be the sandbox's venv, not the template's
187-
assert not (venv_bin / "python").exists() or (venv_bin / "python").is_symlink()
183+
# The .venv present here is the sandbox's own (setup creates one);
184+
# assert the TEMPLATE's copy did not land, via a marker file the real
185+
# venv can never contain. Unconditional on purpose: guarding this
186+
# behind `if venv_bin.exists()` made the whole check vacuous the
187+
# moment provisioning changed, which is exactly how it went
188+
# unnoticed. The marker is also layout-independent -- asserting on
189+
# `.venv/bin/python` would only work on POSIX, since a real venv puts
190+
# its interpreter in `Scripts/python.exe` on Windows.
191+
assert (sandbox_path / ".venv").exists()
192+
assert not (sandbox_path / ".venv" / "from_template.marker").exists()
188193
finally:
189194
sandbox.cleanup(preserve=False)
190195

‎tests/test_sandbox_venv_live.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Live docker check that the sandbox venv does not shadow a task image's packages.
2+
3+
Gated: needs a real docker daemon and the `coder-eval-agent` base image.
4+
5+
This is the one thing the unit tests cannot prove. `tests/test_sandbox.py` asserts
6+
the venv is created with system site packages by reading `pyvenv.cfg`, which is a
7+
property of the flag, not of the outcome. The outcome only exists inside an image
8+
that provisions packages GLOBALLY — the shape every task image has (the framework
9+
image installs with `uv pip install --system`; skillsbench task images do
10+
`RUN pip install ...`). There, an isolated sandbox venv split the toolchain:
11+
`python` resolved to the venv and could not import the image's packages, while
12+
`pip` fell through to the image's global pip and reported them present.
13+
14+
Measured against this test's own scenario:
15+
16+
main (isolated venv) python -c "import pydantic" -> exit 1
17+
with --system-site-packages python -c "import pydantic" -> exit 0
18+
19+
`pydantic` is a coder_eval runtime dependency, so the base image already has it
20+
installed globally — no build and no network are needed to reproduce the shape.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import shutil
26+
import subprocess
27+
import sys
28+
import textwrap
29+
from pathlib import Path
30+
31+
import pytest
32+
33+
34+
BASE_IMAGE = "coder-eval-agent:latest"
35+
REPO_SRC = Path(__file__).resolve().parent.parent / "src"
36+
37+
pytestmark = [
38+
pytest.mark.live,
39+
pytest.mark.skipif(sys.platform == "win32", reason="docker driver is POSIX-only"),
40+
pytest.mark.skipif(shutil.which("docker") is None, reason="docker CLI not available"),
41+
]
42+
43+
44+
def _docker_daemon_up() -> bool:
45+
try:
46+
return subprocess.run(["docker", "info"], capture_output=True, timeout=15).returncode == 0
47+
except (subprocess.TimeoutExpired, FileNotFoundError):
48+
return False
49+
50+
51+
def _image_present(image: str) -> bool:
52+
return subprocess.run(["docker", "image", "inspect", image], capture_output=True, timeout=30).returncode == 0
53+
54+
55+
PROBE = textwrap.dedent(
56+
"""
57+
from coder_eval.models import SandboxConfig
58+
from coder_eval.sandbox import Sandbox
59+
60+
sandbox = Sandbox(SandboxConfig(driver="tempdir"), task_id="venv_probe")
61+
try:
62+
sandbox_dir = sandbox.setup()
63+
import_rc, _, _ = sandbox.run_command('python -c "import pydantic"')
64+
_, prefix, _ = sandbox.run_command('python -c "import sys; print(sys.prefix)"')
65+
print(f"IMPORT_RC={import_rc}")
66+
print(f"PREFIX={prefix.strip()}")
67+
print(f"VENV={sandbox.venv_dir}")
68+
finally:
69+
sandbox.cleanup()
70+
"""
71+
)
72+
73+
74+
def test_criteria_can_import_the_images_global_packages() -> None:
75+
"""A `run_command` criterion must see what the task image installed globally.
76+
77+
Mounts this checkout's `src/` over the image's copy so the assertion is about
78+
the code under test, not whatever coder_eval version the image was built with.
79+
"""
80+
if not _docker_daemon_up():
81+
pytest.skip("docker daemon not running")
82+
if not _image_present(BASE_IMAGE):
83+
pytest.skip(f"{BASE_IMAGE} not built locally")
84+
85+
proc = subprocess.run(
86+
[
87+
"docker",
88+
"run",
89+
"--rm",
90+
"-v",
91+
f"{REPO_SRC}:/opt/coder_eval/src:ro",
92+
"--entrypoint",
93+
"python3",
94+
BASE_IMAGE,
95+
"-c",
96+
PROBE,
97+
],
98+
capture_output=True,
99+
text=True,
100+
timeout=300,
101+
)
102+
assert proc.returncode == 0, f"probe failed:\n{proc.stdout}\n{proc.stderr}"
103+
out = dict(line.split("=", 1) for line in proc.stdout.splitlines() if "=" in line)
104+
105+
assert out["IMPORT_RC"] == "0", (
106+
"a criterion could not import a package the image installed globally -- "
107+
f"the sandbox venv is shadowing the image interpreter again:\n{proc.stdout}"
108+
)
109+
# Isolation still holds: installs land in the sandbox, not the image.
110+
assert out["PREFIX"] == out["VENV"], f"criterion did not run under the sandbox venv:\n{proc.stdout}"

0 commit comments

Comments
 (0)