Skip to content

fix(swesmith): remove answer leaks from the agent sandbox - #788

Open
midat-fx wants to merge 2 commits into
OpenHands:mainfrom
midat-fx:fix/swesmith-strip-git-history
Open

midat-fx wants to merge 2 commits into
OpenHands:mainfrom
midat-fx:fix/swesmith-strip-git-history

Conversation

@midat-fx

@midat-fx midat-fx commented Oct 23, 2026 •

Copy link
Copy Markdown

HUMAN:
I checked this by hand in the funcy image: git branch -a showed 309 task
branches, and one git show printed the injected bug. I read the diff
before opening this PR.

AGENT:
Code and description drafted with Claude Code.

Why

SWE-smith tasks are commits on a per-task branch of the mirror repo: Bug Patch injects the bug, and Remove F2P Tests deletes the tests that grade it. The image's /testbed is a full clone of the mirror, checked out at the clean commit. run_infer.py copies it into /workspace/<repo> with its .git, then fetches and checks out the task branch. Before writing any code, the agent can recover the fix three ways:

  1. In the workspace: git log -p, git show HEAD~1 (the injected bug, often with comments like # Subtle swap: ...), git show HEAD (the removed hidden tests).
  2. In the workspace or /testbed: git branch -r lists every task branch of the repo (308 for Suor/funcy), each with its own bug patch.
  3. In /testbed, without git: the working tree is the original, pre-bug code. The repo is also installed in editable mode from /testbed, so tracebacks point there.

Checked in swebench/swesmith.x86_64.suor_1776_funcy.207a7810, running as uid 10001, the agent-server user:

$ stat -c '%U:%G %a %n' /testbed /testbed/.git
root:root 755 /testbed
root:root 755 /testbed/.git
$ grep -n 'return f(k), v' /testbed/funcy/colls.py      # line the bug replaces
138:        return f(k), v
$ git config --global --add safe.directory /testbed   # what git's own error suggests
$ git -C /testbed branch -a | wc -l
309
$ git -C /testbed show origin/Suor__funcy.207a7810.combine_file__b3usvi20~1 -- funcy/colls.py
-        return f(k), v
+        return k, f(v)  # Subtle swap: applying the mapping function to the value instead of the key
$ rm -rf /testbed/.git
rm: cannot remove '/testbed/.git/HEAD': Permission denied

Reverting the bug patch passes the hidden tests: tests/test_colls.py goes from 2 failed / 48 passed to 50 passed. Results and training trajectories can come from copying history instead of fixing the bug. This is the same class of hole as #421 / #422 (commit0 git history).

Summary

  • strip_git_history_command() runs after the task checkout. It turns the workspace tree into a single orphan commit, deletes every other ref and the remotes, drops FETCH_HEAD/ORIG_HEAD, expires the reflog and runs git gc --prune=now. base_commit is read afterwards, and the tree is unchanged, so the patch sent to grading is the same.
  • replace_testbed_command() runs next: find /testbed -mindepth 1 -delete && cp -a <workspace>/. /testbed/. /testbed becomes the stripped task tree, and the editable install imports the task code instead of the clean copy.
  • New benchmarks/swesmith/Dockerfile.testbed-access (find /testbed -type d -exec chown openhands:openhands {} +). The agent user cannot change root-owned /testbed, and unlinking a file only needs write access to its directory. Chowning directories only keeps the layer at 0 B.
  • build_images.py applies the layer to every built agent-server image through post_build_fn, the same pattern as the SWE-bench docutils wrapper. run_infer.py does the same for SKIP_BUILD=0 local builds. With an image built without the layer, the /testbed step fails with an explicit "rebuild the SWE-smith images" assertion instead of running a leaky task.

The task commit is still fetched from the mirror URL at runtime, not from /testbed, so the fetch path is unchanged.

Issue Number

None. Related: #421 (same problem in commit0, fixed by #422).

How to Test

uv run pytest --forked -q tests/test_swesmith_strip_git_history.py
4 passed
uv run pre-commit run --files benchmarks/swesmith/run_infer.py benchmarks/swesmith/build_images.py \
    benchmarks/swesmith/Dockerfile.testbed-access tests/test_swesmith_strip_git_history.py
ruff format / ruff lint / pycodestyle / pyright (strict): Passed

The tests build a temporary mirror with main and two task branches, a clean full clone standing in for /testbed, and a workspace prepared the way run_infer.py does. They check:

  • after the strip, one reachable commit, an unchanged tree, no remotes, an empty reflog, and the Bug Patch object gone;
  • an agent edit on top yields the expected git diff <base> HEAD;
  • after the replacement, /testbed matches the workspace: buggy code, no removed tests, one commit, no remotes;
  • wrap_testbed_access builds Dockerfile.testbed-access on top of each built image tag with SDK_IMAGE set.

In Docker, linux/amd64: the real funcy image, plus a stand-in for the agent-server layers (user 10001 owning /workspace), plus the real Dockerfile.testbed-access. The runtime commands ran as uid 10001 against the real mirror:

=== without the layer
strip: ok
replace_exit=1
testbed: refs=309 commits=613 remotes=1 clean_line=1
=== with Dockerfile.testbed-access
strip: ok
replace_exit=0
testbed: refs=1 commits=1 remotes=0 clean_line=0
import sees task code: True

docker history shows the added layer as 0 B.

Notes

  • After merging, rebuild and push the SWE-smith agent-server images (build-swesmith-images.yml / build_images.py). Image tags are keyed by SDK SHA, so an image built before this change keeps its tag. Until it is rebuilt, the new /testbed step fails it loudly.
  • Not run: a full SDK agent-server image build. The SDK Dockerfile does not touch /testbed; it only chowns /workspace. That is why a stand-in replaced those layers in the Docker check.
  • Grading is unaffected: SWE-smith evaluation runs in a fresh container from the image and applies the patch there.
  • git gc and the second copy add some seconds per instance on large repos.
  • This PR was prepared with AI assistance (Claude Code): finding and reproducing the leak, the change and this description.

midat-fx and others added 2 commits October 24, 2026 00:56
A SWE-smith task branch stores the injected bug as a "Bug Patch" commit
and the hidden tests' removal as "Remove F2P Tests". The image's
/testbed is a full clone of the mirror, so the copied workspace also has
every task branch as a remote ref (308 for Suor/funcy). An agent can run
`git log -p` or `git show origin/<task>~1`, reverse the bug patch, and
pass the hidden tests without solving anything; reverting it on a real
task took tests/test_colls.py from 2 failed to 50 passed.

After checkout, keep only the current tree as a single orphan commit,
delete every other ref and the remotes, expire the reflog and prune
unreachable objects. base_commit is taken after this, and the tree is
unchanged, so the patch sent to grading is the same.

Same class of fix as OpenHands#422 (commit0 shallow clone).

Co-authored-by: openhands <openhands@all-hands.dev>
Assisted-by: Claude (Anthropic)
Stripping the workspace copy is not enough: the image's /testbed is the
mirror clone itself. Its working tree is the clean, pre-bug code, its
.git holds every task branch, and the repo is installed in editable mode
from it. Checked in swebench/swesmith.x86_64.suor_1776_funcy.207a7810 as
uid 10001 (the agent-server user): /testbed is root:root 755 and
readable, colls.py still has the original line, and after the
safe.directory hint git lists 309 branches including the task's bug
patch. The agent user cannot change /testbed, so this needs an image
layer.

- Dockerfile.testbed-access: chown /testbed's directories (not files) to
  the agent user, so it can unlink entries; the layer is 0 B.
- build_images.py wraps every built agent-server image with it via
  post_build_fn, like the SWE-bench docutils wrapper; run_infer.py does
  the same for SKIP_BUILD=0 local builds.
- run_infer.py replaces /testbed's contents with the stripped workspace
  after the history strip. The editable install then imports the task
  code. An image without the layer fails here loudly instead of leaking.

Co-authored-by: openhands <openhands@all-hands.dev>
Assisted-by: Claude (Anthropic)
@midat-fx
midat-fx marked this pull request as ready for review October 24, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant