-
Notifications
You must be signed in to change notification settings - Fork 37
fix(sandbox): stop with-skill builds poisoning no-skill rollouts #938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1150,6 +1150,12 @@ async def install_agent(self) -> None: | |
| self._agent_cwd, | ||
| skills_sandbox_dir=self._effective_skills_sandbox_dir, | ||
| ) | ||
| if cfg.recorded_skill_mode == SKILL_MODE_NO_SKILL: | ||
| # Verify the condition rather than assume it: a stale image can | ||
| # carry skills that no deployment path in this rollout put there. | ||
| await self._planes.assert_no_skill_isolation( | ||
| self._env, self._agent_cfg, cfg.sandbox_user | ||
| ) | ||
|
Comment on lines
+1156
to
+1158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In no-skill rollouts that supply a custom plane bundle, this unconditional call now raises Useful? React with 👍 / 👎. |
||
| if cfg.export_generated_skills_to: | ||
| await _ensure_sandbox_dir( | ||
| self._env, cfg.generated_skills_root, cfg.sandbox_user | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import asyncio | ||
| import asyncio.subprocess | ||
| import contextlib | ||
| import hashlib | ||
| import json | ||
| import logging | ||
| import os | ||
|
|
@@ -69,6 +70,36 @@ def _sanitize_docker_image_name(name: str) -> str: | |
| return name | ||
|
|
||
|
|
||
| def _build_context_fingerprint(environment_dir: Path) -> str: | ||
| """Short digest of the inputs that decide what the built image contains. | ||
|
|
||
| The image tag must distinguish builds whose contents differ. The Dockerfile | ||
| is rewritten in place when skills are baked in (``--skills-dir`` appends | ||
| ``COPY _deps/skills`` plus symlinks into every agent skill path), so a tag | ||
| derived from the task name alone is shared by a with-skill and a no-skill | ||
| build of the same task. Whichever built last wins, and the other rollout | ||
| starts from it — a no-skill rollout then runs in an image that already | ||
| contains ``/skills`` and the agent-side symlinks, silently invalidating the | ||
| no-skill condition. | ||
|
|
||
| Hashing the Dockerfile, and the presence of the staged skills payload, | ||
| gives the two builds different tags. | ||
| """ | ||
| hasher = hashlib.sha256() | ||
| dockerfile = environment_dir / "Dockerfile" | ||
| if dockerfile.is_file(): | ||
| hasher.update(dockerfile.read_bytes()) | ||
| # The staged payload is what COPY pulls in; include its layout so a change | ||
| # of skills — not just of the Dockerfile line — yields a different tag. | ||
| skills_root = environment_dir / "_deps" / "skills" | ||
| if skills_root.is_dir(): | ||
| for path in sorted(skills_root.rglob("*")): | ||
| hasher.update(str(path.relative_to(skills_root)).encode()) | ||
| if path.is_file(): | ||
| hasher.update(str(path.stat().st_size).encode()) | ||
|
Comment on lines
+96
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two with-skill rollouts use the same task and skill paths but different same-length Useful? React with 👍 / 👎. |
||
| return hasher.hexdigest()[:12] | ||
|
|
||
|
|
||
| def _sanitize_docker_compose_project_name(name: str) -> str: | ||
| name = name.lower() | ||
| if not re.match(r"^[a-z0-9]", name): | ||
|
|
@@ -191,7 +222,10 @@ def __init__( | |
| ) | ||
|
|
||
| self._env_vars = DockerSandboxEnvVars( | ||
| main_image_name=_sanitize_docker_image_name(f"bf__{environment_name}"), | ||
| main_image_name=_sanitize_docker_image_name( | ||
| f"bf__{environment_name}__" | ||
| f"{_build_context_fingerprint(self.environment_dir)}" | ||
| ), | ||
| context_dir=str(self.environment_dir.resolve().absolute()), | ||
| host_verifier_logs_path=verifier_dir, | ||
| host_agent_logs_path=agent_dir, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Regression tests for no-skill rollout isolation. | ||
|
|
||
| A with-skill build rewrites the task Dockerfile in place (``COPY _deps/skills`` | ||
| plus symlinks into every agent skill path). While the image tag was derived from | ||
| the task name alone, that build and a no-skill build of the same task shared one | ||
| tag, so whichever ran last decided what both started from — and a no-skill | ||
| rollout could silently run inside an image containing ``/skills``. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import ClassVar | ||
|
|
||
| import pytest | ||
|
|
||
| from benchflow.agents.install import assert_no_skill_isolation | ||
| from benchflow.sandbox.docker import _build_context_fingerprint | ||
|
|
||
|
|
||
| def _write_env(tmp_path, dockerfile_body: str, skills: dict[str, str] | None = None): | ||
| env_dir = tmp_path / "environment" | ||
| env_dir.mkdir(parents=True, exist_ok=True) | ||
| (env_dir / "Dockerfile").write_text(dockerfile_body) | ||
| if skills: | ||
| for rel, content in skills.items(): | ||
| target = env_dir / "_deps" / "skills" / rel | ||
| target.parent.mkdir(parents=True, exist_ok=True) | ||
| target.write_text(content) | ||
| return env_dir | ||
|
|
||
|
|
||
| BASE_DOCKERFILE = "FROM python:3.12-slim\nCOPY payload.stl /root/payload.stl\n" | ||
| SKILL_INJECTED = BASE_DOCKERFILE + ( | ||
| "\n# Skills directory (injected by benchflow --skills-dir)\n" | ||
| "COPY _deps/skills /skills/\n" | ||
| "RUN mkdir -p /home/agent/.opencode && ln -sf /skills /home/agent/.opencode/skills\n" | ||
| ) | ||
|
|
||
|
|
||
| def test_fingerprint_is_stable_for_identical_context(tmp_path): | ||
| a = _write_env(tmp_path / "a", BASE_DOCKERFILE) | ||
| b = _write_env(tmp_path / "b", BASE_DOCKERFILE) | ||
| assert _build_context_fingerprint(a) == _build_context_fingerprint(b) | ||
|
|
||
|
|
||
| def test_skill_injection_changes_the_fingerprint(tmp_path): | ||
| """The core regression: the two builds must not share an image tag.""" | ||
|
Comment on lines
+46
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This test explicitly identifies itself as guarding the core regression, but its docstring names neither a PR nor a commit. Add the identifier for the change it guards so future maintainers can trace the intended behavior as required by the repository convention. AGENTS.md reference: AGENTS.md:L16-L17 Useful? React with 👍 / 👎. |
||
| clean = _write_env(tmp_path / "clean", BASE_DOCKERFILE) | ||
| injected = _write_env( | ||
| tmp_path / "injected", | ||
| SKILL_INJECTED, | ||
| skills={"mentor/SKILL.md": "# mentor\n"}, | ||
| ) | ||
| assert _build_context_fingerprint(clean) != _build_context_fingerprint(injected) | ||
|
|
||
|
|
||
| def test_changing_skill_payload_changes_the_fingerprint(tmp_path): | ||
| one = _write_env( | ||
| tmp_path / "one", SKILL_INJECTED, skills={"mentor/SKILL.md": "# mentor\n"} | ||
| ) | ||
| two = _write_env( | ||
| tmp_path / "two", | ||
| SKILL_INJECTED, | ||
| skills={"mentor/SKILL.md": "# mentor\n", "second/SKILL.md": "# second\n"}, | ||
| ) | ||
| assert _build_context_fingerprint(one) != _build_context_fingerprint(two) | ||
|
|
||
|
|
||
| class _FakeEnv: | ||
| """Minimal env double returning canned `find` output.""" | ||
|
|
||
| def __init__(self, stdout: str): | ||
| self._stdout = stdout | ||
| self.commands: list[str] = [] | ||
|
|
||
| async def exec(self, cmd: str, timeout_sec: int = 20): | ||
| self.commands.append(cmd) | ||
|
|
||
| class _Result: | ||
| stdout = self._stdout | ||
| stderr = "" | ||
| return_code = 0 | ||
|
|
||
| return _Result() | ||
|
|
||
|
|
||
| class _FakeAgentCfg: | ||
| skill_paths: ClassVar[list[str]] = ["$HOME/.opencode/skills"] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_isolation_check_passes_when_no_skills_present(): | ||
| env = _FakeEnv("") | ||
| await assert_no_skill_isolation(env, _FakeAgentCfg(), "agent") | ||
| assert "/home/agent/.opencode/skills" in env.commands[0] | ||
| assert "/skills" in env.commands[0] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_isolation_check_raises_when_skills_are_reachable(): | ||
| env = _FakeEnv("/skills/ion-shuttling-mentor/SKILL.md\n") | ||
| with pytest.raises(RuntimeError, match="no-skill rollout is contaminated"): | ||
| await assert_no_skill_isolation(env, _FakeAgentCfg(), "agent") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_isolation_check_handles_missing_agent_config(): | ||
| """The oracle path passes no agent config; the mount point is still checked.""" | ||
| env = _FakeEnv("") | ||
| await assert_no_skill_isolation(env, None, None) | ||
| assert "/skills" in env.commands[0] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_isolation_check_ignores_non_skill_output(): | ||
| """Shell noise on stdout must not be mistaken for a contaminated sandbox.""" | ||
| env = _FakeEnv("/workspace\nsome unrelated line\n") | ||
| await assert_no_skill_isolation(env, _FakeAgentCfg(), "agent") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For agents with
$WORKSPACEdiscovery paths, such as OpenHands and OpenClaw, this only expands$HOME;shlex.quote()then makes$WORKSPACE/.agents/skillsa literal relative path. A no-skill sandbox containing guidance only at the actual workspace discovery path therefore passes this guard and can still produce a contaminated result. Pass the agent cwd and expand$WORKSPACEexactly as_link_skill_paths()does.AGENTS.md reference: AGENTS.md:L30-L30
Useful? React with 👍 / 👎.